From: Raphaël Gertz Date: Tue, 2 Apr 2024 03:42:10 +0000 (+0200) Subject: Add captcha option X-Git-Tag: 0.5.0 X-Git-Url: https://git.rapsys.eu/packbundle/commitdiff_plain/HEAD?hp=386a447467d7a5d368946fcd97592735bb85683f Add captcha option --- diff --git a/Asset/PathPackage.php b/Asset/PathPackage.php deleted file mode 100644 index 50c6172..0000000 --- a/Asset/PathPackage.php +++ /dev/null @@ -1,56 +0,0 @@ -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 index 0000000..0097d12 --- /dev/null +++ b/Command.php @@ -0,0 +1,74 @@ + + * + * 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 index 0000000..7c3270d --- /dev/null +++ b/Command/RangeCommand.php @@ -0,0 +1,129 @@ + + * + * 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 index 0000000..9f728cb --- /dev/null +++ b/Context/NullContext.php @@ -0,0 +1,28 @@ + + * + * 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 index 0000000..366e167 --- /dev/null +++ b/Context/RequestStackContext.php @@ -0,0 +1,44 @@ + + * + * 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 index 0000000..13865a7 --- /dev/null +++ b/Controller/ImageController.php @@ -0,0 +1,291 @@ + + * + * 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 index 0000000..8d38aa6 --- /dev/null +++ b/Controller/MapController.php @@ -0,0 +1,507 @@ + + * + * 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; + } +} diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 22ae9d9..60b68b6 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -1,161 +1,160 @@ - + * + * 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. 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(); diff --git a/DependencyInjection/RapsysPackExtension.php b/DependencyInjection/RapsysPackExtension.php index 4605e96..fc9efce 100644 --- a/DependencyInjection/RapsysPackExtension.php +++ b/DependencyInjection/RapsysPackExtension.php @@ -1,7 +1,18 @@ - + * + * 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 index 0000000..7107548 --- /dev/null +++ b/Extension/PackExtension.php @@ -0,0 +1,70 @@ + + * + * 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') + ]; + } +} diff --git a/Twig/Filter/CPackFilter.php b/Filter/CPackFilter.php similarity index 66% rename from Twig/Filter/CPackFilter.php rename to Filter/CPackFilter.php index ca38c3b..f56bb95 100644 --- a/Twig/Filter/CPackFilter.php +++ b/Filter/CPackFilter.php @@ -1,42 +1,34 @@ - + * + * 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 index 0000000..f3b2987 --- /dev/null +++ b/Filter/FilterInterface.php @@ -0,0 +1,27 @@ + + * + * 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; +} diff --git a/Twig/Filter/JPackFilter.php b/Filter/JPackFilter.php similarity index 65% rename from Twig/Filter/JPackFilter.php rename to Filter/JPackFilter.php index ca4555d..9d758e4 100644 --- a/Twig/Filter/JPackFilter.php +++ b/Filter/JPackFilter.php @@ -1,48 +1,42 @@ - + * + * 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 index 0000000..c3a5f69 --- /dev/null +++ b/Form/CaptchaType.php @@ -0,0 +1,116 @@ + + * + * 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' => ''.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 index 0000000..c53b10f --- /dev/null +++ b/Package/PathPackage.php @@ -0,0 +1,100 @@ + + * + * 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); + } +} diff --git a/Twig/PackTokenParser.php b/Parser/TokenParser.php similarity index 61% rename from Twig/PackTokenParser.php rename to Parser/TokenParser.php index c4bbe4c..67e87eb 100644 --- a/Twig/PackTokenParser.php +++ b/Parser/TokenParser.php @@ -1,9 +1,23 @@ - + * + * 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); } } diff --git a/README.md b/README.md index 3f3ab87..81cd07a 100644 --- 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 . + +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 @@ -169,15 +269,14 @@ You can create you own mypackfilter class which call a mypack binary: ```php 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. diff --git a/RapsysPackBundle.php b/RapsysPackBundle.php index d1fd290..40e0563 100644 --- a/RapsysPackBundle.php +++ b/RapsysPackBundle.php @@ -1,7 +1,69 @@ - + * + * 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 index 0fe0346..0000000 --- a/Resources/config/packages/rapsys_pack.yaml +++ /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 index 0000000..f57cf74 --- /dev/null +++ b/Resources/config/packages/rapsyspack.yaml @@ -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 index 0000000..aa0a953 --- /dev/null +++ b/Resources/config/routes/rapsyspack.yaml @@ -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/img/.keep b/Resources/public/facebook/.keep similarity index 100% rename from Resources/public/img/.keep rename to Resources/public/facebook/.keep diff --git a/Resources/public/image/.keep b/Resources/public/image/.keep new file mode 100644 index 0000000..e69de29 diff --git a/Resources/public/map/.keep b/Resources/public/map/.keep new file mode 100644 index 0000000..e69de29 diff --git a/Subscriber/FacebookSubscriber.php b/Subscriber/FacebookSubscriber.php new file mode 100644 index 0000000..af4eb0f --- /dev/null +++ b/Subscriber/FacebookSubscriber.php @@ -0,0 +1,79 @@ + + * + * 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 index 2dc2b6f..0000000 --- a/Twig/Filter/FilterInterface.php +++ /dev/null @@ -1,9 +0,0 @@ -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 index 0000000..91664d1 --- /dev/null +++ b/Util/FacebookUtil.php @@ -0,0 +1,357 @@ + + * + * 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 index 0000000..871b99a --- /dev/null +++ b/Util/ImageUtil.php @@ -0,0 +1,231 @@ + + * + * 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 index 0000000..b37169d --- /dev/null +++ b/Util/IntlUtil.php @@ -0,0 +1,167 @@ + + * + * 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 index 0000000..b7d2232 --- /dev/null +++ b/Util/MapUtil.php @@ -0,0 +1,428 @@ + + * + * 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 index 0000000..eb6041e --- /dev/null +++ b/Util/SluggerUtil.php @@ -0,0 +1,241 @@ + + * + * 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; + } +} diff --git a/composer.json b/composer.json index 0d6f49d..3c7f24b 100644 --- a/composer.json +++ b/composer.json @@ -5,7 +5,7 @@ "type": "symfony-bundle", "authors": [{ "name": "Raphaël Gertz", - "email": "packbundle@rapsys.eu" + "email": "symfony@rapsys.eu" }], "autoload": { "psr-4": { @@ -13,11 +13,11 @@ } }, "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": {