]> Raphaël G. Git Repositories - packbundle/blob - Controller/CaptchaController.php
Reduce captcha watermark stroke width
[packbundle] / Controller / CaptchaController.php
1 <?php declare(strict_types=1);
2
3 /*
4 * This file is part of the Rapsys PackBundle package.
5 *
6 * (c) Raphaël Gertz <symfony@rapsys.eu>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12 namespace Rapsys\PackBundle\Controller;
13
14 use Symfony\Component\HttpFoundation\HeaderUtils;
15 use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
16 use Symfony\Component\DependencyInjection\ContainerInterface;
17 use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
18 use Symfony\Component\Filesystem\Filesystem;
19 use Symfony\Component\HttpFoundation\BinaryFileResponse;
20 use Symfony\Component\HttpFoundation\Request;
21 use Symfony\Component\HttpFoundation\Response;
22 use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
23 use Symfony\Component\Routing\RequestContext;
24 use Symfony\Contracts\Service\ServiceSubscriberInterface;
25
26 use Rapsys\PackBundle\Util\MapUtil;
27 use Rapsys\PackBundle\Util\SluggerUtil;
28
29 /**
30 * {@inheritdoc}
31 */
32 class CaptchaController extends AbstractController implements ServiceSubscriberInterface {
33 /**
34 * The cache path
35 */
36 protected string $cache;
37
38 /**
39 * The stream context instance
40 */
41 protected mixed $ctx;
42
43 /**
44 * The MapUtil instance
45 */
46 protected MapUtil $map;
47
48 /**
49 * The public path
50 */
51 protected string $path;
52
53 /**
54 * The SluggerUtil instance
55 */
56 protected SluggerUtil $slugger;
57
58 /**
59 * The tile server url
60 */
61 protected string $url;
62
63 /**
64 * Creates a new captcha controller
65 *
66 * @param ContainerInterface $container The ContainerInterface instance
67 * @param SluggerUtil $slugger The SluggerUtil instance
68 * @param string $cache The cache path
69 * @param string $path The public path
70 * @param string $prefix The prefix
71 */
72 function __construct(ContainerInterface $container, SluggerUtil $slugger, string $cache = '../var/cache', string $path = './bundles/rapsyspack', string $prefix = 'captcha') {
73 //Set cache
74 $this->cache = $cache.'/'.$prefix;
75
76 //Set container
77 $this->container = $container;
78
79 //Set path
80 $this->path = $path.'/'.$prefix;
81
82 //Set slugger
83 $this->slugger = $slugger;
84 }
85
86 /**
87 * Return map image
88 *
89 * @param Request $request The Request instance
90 * @param string $hash The hash
91 * @param int $updated The updated timestamp
92 * @param float $latitude The latitude
93 * @param float $longitude The longitude
94 * @param int $zoom The zoom
95 * @param int $width The width
96 * @param int $height The height
97 * @return Response The rendered image
98 */
99 public function map(Request $request, string $hash, int $updated, float $latitude, float $longitude, int $zoom, int $width, int $height): Response {
100 //Without matching hash
101 if ($hash !== $this->slugger->hash([$updated, $latitude, $longitude, $zoom, $width, $height])) {
102 //Throw new exception
103 throw new NotFoundHttpException(sprintf('Unable to match map hash: %s', $hash));
104 }
105
106 //Set map
107 $map = $this->path.'/'.$zoom.'/'.$latitude.'/'.$longitude.'/'.$width.'x'.$height.'.jpeg';
108
109 //Without multi up to date file
110 if (!is_file($map) || !($mtime = stat($map)['mtime']) || $mtime < $updated) {
111 //Without existing map path
112 if (!is_dir($dir = dirname($map))) {
113 //Create filesystem object
114 $filesystem = new Filesystem();
115
116 try {
117 //Create path
118 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
119 //XXX: on CoW filesystems execute a chattr +C before filling
120 $filesystem->mkdir($dir, 0775);
121 } catch (IOExceptionInterface $e) {
122 //Throw error
123 throw new \Exception(sprintf('Output path "%s" do not exists and unable to create it', $dir), 0, $e);
124 }
125 }
126
127 //Create image instance
128 $image = new \Imagick();
129
130 //Add new image
131 $image->newImage($width, $height, new \ImagickPixel('transparent'), 'jpeg');
132
133 //Create tile instance
134 $tile = new \Imagick();
135
136 //Get tile xy
137 $centerX = $this->map->longitudeToX($longitude, $zoom);
138 $centerY = $this->map->latitudeToY($latitude, $zoom);
139
140 //Calculate start xy
141 $startX = floor(floor($centerX) - $width / MapUtil::tz);
142 $startY = floor(floor($centerY) - $height / MapUtil::tz);
143
144 //Calculate end xy
145 $endX = ceil(ceil($centerX) + $width / MapUtil::tz);
146 $endY = ceil(ceil($centerY) + $height / MapUtil::tz);
147
148 for($x = $startX; $x <= $endX; $x++) {
149 for($y = $startY; $y <= $endY; $y++) {
150 //Set cache path
151 $cache = $this->cache.'/'.$zoom.'/'.$x.'/'.$y.'.png';
152
153 //Without cache image
154 if (!is_file($cache)) {
155 //Set tile url
156 $tileUri = str_replace(['{Z}', '{X}', '{Y}'], [$zoom, $x, $y], $this->url);
157
158 //Without cache path
159 if (!is_dir($dir = dirname($cache))) {
160 //Create filesystem object
161 $filesystem = new Filesystem();
162
163 try {
164 //Create path
165 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
166 $filesystem->mkdir($dir, 0775);
167 } catch (IOExceptionInterface $e) {
168 //Throw error
169 throw new \Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e);
170 }
171 }
172
173 //Store tile in cache
174 file_put_contents($cache, file_get_contents($tileUri, false, $this->ctx));
175 }
176
177 //Set dest x
178 $destX = intval(floor($width / 2 - MapUtil::tz * ($centerX - $x)));
179
180 //Set dest y
181 $destY = intval(floor($height / 2 - MapUtil::tz * ($centerY - $y)));
182
183 //Read tile from cache
184 $tile->readImage($cache);
185
186 //Compose image
187 $image->compositeImage($tile, \Imagick::COMPOSITE_OVER, $destX, $destY);
188
189 //Clear tile
190 $tile->clear();
191 }
192 }
193
194 //Add imagick draw instance
195 //XXX: see https://www.php.net/manual/fr/imagick.examples-1.php#example-3916
196 $draw = new \ImagickDraw();
197
198 //Set text antialias
199 $draw->setTextAntialias(true);
200
201 //Set stroke antialias
202 $draw->setStrokeAntialias(true);
203
204 //Set text alignment
205 $draw->setTextAlignment(\Imagick::ALIGN_CENTER);
206
207 //Set gravity
208 $draw->setGravity(\Imagick::GRAVITY_CENTER);
209
210 //Set fill color
211 $draw->setFillColor('#cff');
212
213 //Set stroke color
214 $draw->setStrokeColor('#00c3f9');
215
216 //Set stroke width
217 $draw->setStrokeWidth(2);
218
219 //Draw circle
220 $draw->circle($width/2 - 5, $height/2 - 5, $width/2 + 5, $height/2 + 5);
221
222 //Draw on image
223 $image->drawImage($draw);
224
225 //Strip image exif data and properties
226 $image->stripImage();
227
228 //Add latitude
229 //XXX: not supported by imagick :'(
230 $image->setImageProperty('exif:GPSLatitude', $this->map->latitudeToSexagesimal($latitude));
231
232 //Add longitude
233 //XXX: not supported by imagick :'(
234 $image->setImageProperty('exif:GPSLongitude', $this->map->longitudeToSexagesimal($longitude));
235
236 //Add description
237 //XXX: not supported by imagick :'(
238 #$image->setImageProperty('exif:Description', $caption);
239
240 //Set progressive jpeg
241 $image->setInterlaceScheme(\Imagick::INTERLACE_PLANE);
242
243 //Set compression quality
244 //TODO: ajust that
245 $image->setImageCompressionQuality(70);
246
247 //Save image
248 if (!$image->writeImage($map)) {
249 //Throw error
250 throw new \Exception(sprintf('Unable to write image "%s"', $path));
251 }
252
253 //Set mtime
254 $mtime = stat($map)['mtime'];
255 }
256
257 //Read map from cache
258 $response = new BinaryFileResponse($map);
259
260 //Set file name
261 $response->setContentDisposition(HeaderUtils::DISPOSITION_INLINE, 'map-'.$latitude.','.$longitude.'-'.$zoom.'-'.$width.'x'.$height.'.jpeg');
262
263 //Set etag
264 $response->setEtag(md5(serialize([$updated, $latitude, $longitude, $zoom, $width, $height])));
265
266 //Set last modified
267 $response->setLastModified(\DateTime::createFromFormat('U', strval($mtime)));
268
269 //Set as public
270 $response->setPublic();
271
272 //Return 304 response if not modified
273 $response->isNotModified($request);
274
275 //Return response
276 return $response;
277 }
278
279 /**
280 * Return multi map image
281 *
282 * @param Request $request The Request instance
283 * @param string $hash The hash
284 * @param int $updated The updated timestamp
285 * @param float $latitude The latitude
286 * @param float $longitude The longitude
287 * @param string $coordinates The coordinates
288 * @param int $zoom The zoom
289 * @param int $width The width
290 * @param int $height The height
291 * @return Response The rendered image
292 */
293 public function multiMap(Request $request, string $hash, int $updated, float $latitude, float $longitude, string $coordinates, int $zoom, int $width, int $height): Response {
294 //Without matching hash
295 if ($hash !== $this->slugger->hash([$updated, $latitude, $longitude, $coordinate = $this->slugger->hash($coordinates), $zoom, $width, $height])) {
296 //Throw new exception
297 throw new NotFoundHttpException(sprintf('Unable to match multi map hash: %s', $hash));
298 }
299
300 //Set multi
301 $map = $this->path.'/'.$zoom.'/'.$latitude.'/'.$longitude.'/'.$coordinate.'/'.$width.'x'.$height.'.jpeg';
302
303 //Without multi up to date file
304 if (!is_file($map) || !($mtime = stat($map)['mtime']) || $mtime < $updated) {
305 //Without existing multi path
306 if (!is_dir($dir = dirname($map))) {
307 //Create filesystem object
308 $filesystem = new Filesystem();
309
310 try {
311 //Create path
312 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
313 //XXX: on CoW filesystems execute a chattr +C before filling
314 $filesystem->mkdir($dir, 0775);
315 } catch (IOExceptionInterface $e) {
316 //Throw error
317 throw new \Exception(sprintf('Output path "%s" do not exists and unable to create it', $dir), 0, $e);
318 }
319 }
320
321 //Create image instance
322 $image = new \Imagick();
323
324 //Add new image
325 $image->newImage($width, $height, new \ImagickPixel('transparent'), 'jpeg');
326
327 //Create tile instance
328 $tile = new \Imagick();
329
330 //Get tile xy
331 $centerX = $this->map->longitudeToX($longitude, $zoom);
332 $centerY = $this->map->latitudeToY($latitude, $zoom);
333
334 //Calculate start xy
335 $startX = floor(floor($centerX) - $width / MapUtil::tz);
336 $startY = floor(floor($centerY) - $height / MapUtil::tz);
337
338 //Calculate end xy
339 $endX = ceil(ceil($centerX) + $width / MapUtil::tz);
340 $endY = ceil(ceil($centerY) + $height / MapUtil::tz);
341
342 for($x = $startX; $x <= $endX; $x++) {
343 for($y = $startY; $y <= $endY; $y++) {
344 //Set cache path
345 $cache = $this->cache.'/'.$zoom.'/'.$x.'/'.$y.'.png';
346
347 //Without cache image
348 if (!is_file($cache)) {
349 //Set tile url
350 $tileUri = str_replace(['{Z}', '{X}', '{Y}'], [$zoom, $x, $y], $this->url);
351
352 //Without cache path
353 if (!is_dir($dir = dirname($cache))) {
354 //Create filesystem object
355 $filesystem = new Filesystem();
356
357 try {
358 //Create path
359 //XXX: set as 0775, symfony umask (0022) will reduce rights (0755)
360 $filesystem->mkdir($dir, 0775);
361 } catch (IOExceptionInterface $e) {
362 //Throw error
363 throw new \Exception(sprintf('Output directory "%s" do not exists and unable to create it', $dir), 0, $e);
364 }
365 }
366
367 //Store tile in cache
368 file_put_contents($cache, file_get_contents($tileUri, false, $this->ctx));
369 }
370
371 //Set dest x
372 $destX = intval(floor($width / 2 - MapUtil::tz * ($centerX - $x)));
373
374 //Set dest y
375 $destY = intval(floor($height / 2 - MapUtil::tz * ($centerY - $y)));
376
377 //Read tile from cache
378 $tile->readImage($cache);
379
380 //Compose image
381 $image->compositeImage($tile, \Imagick::COMPOSITE_OVER, $destX, $destY);
382
383 //Clear tile
384 $tile->clear();
385 }
386 }
387
388 //Add imagick draw instance
389 //XXX: see https://www.php.net/manual/fr/imagick.examples-1.php#example-3916
390 $draw = new \ImagickDraw();
391
392 //Set text antialias
393 $draw->setTextAntialias(true);
394
395 //Set stroke antialias
396 $draw->setStrokeAntialias(true);
397
398 //Set text alignment
399 $draw->setTextAlignment(\Imagick::ALIGN_CENTER);
400
401 //Set gravity
402 $draw->setGravity(\Imagick::GRAVITY_CENTER);
403
404 //Convert to array
405 $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);
406
407 //Iterate on locations
408 foreach($coordinates as $id => $coordinate) {
409 //Set dest x
410 $destX = intval(floor($width / 2 - MapUtil::tz * ($centerX - $this->map->longitudeToX(floatval($coordinate['longitude']), $zoom))));
411
412 //Set dest y
413 $destY = intval(floor($height / 2 - MapUtil::tz * ($centerY - $this->map->latitudeToY(floatval($coordinate['latitude']), $zoom))));
414
415 //Set fill color
416 $draw->setFillColor($this->map->fill);
417
418 //Set font size
419 $draw->setFontSize($this->map->fontSize);
420
421 //Set stroke color
422 $draw->setStrokeColor($this->map->stroke);
423
424 //Set circle radius
425 $radius = $this->map->radius;
426
427 //Set stroke width
428 $stroke = $this->map->strokeWidth;
429
430 //With matching position
431 if ($coordinate['latitude'] === $latitude && $coordinate['longitude'] == $longitude) {
432 //Set fill color
433 $draw->setFillColor($this->map->highFill);
434
435 //Set font size
436 $draw->setFontSize($this->map->highFontSize);
437
438 //Set stroke color
439 $draw->setStrokeColor($this->map->highStroke);
440
441 //Set circle radius
442 $radius = $this->map->highRadius;
443
444 //Set stroke width
445 $stroke = $this->map->highStrokeWidth;
446 }
447
448 //Set stroke width
449 $draw->setStrokeWidth($stroke);
450
451 //Draw circle
452 $draw->circle($destX - $radius, $destY - $radius, $destX + $radius, $destY + $radius);
453
454 //Set fill color
455 $draw->setFillColor($draw->getStrokeColor());
456
457 //Set stroke width
458 $draw->setStrokeWidth($stroke / 4);
459
460 //Get font metrics
461 $metrics = $image->queryFontMetrics($draw, strval($id));
462
463 //Add annotation
464 $draw->annotation($destX - $radius, $destY + $stroke, strval($id));
465 }
466
467 //Draw on image
468 $image->drawImage($draw);
469
470 //Strip image exif data and properties
471 $image->stripImage();
472
473 //Add latitude
474 //XXX: not supported by imagick :'(
475 $image->setImageProperty('exif:GPSLatitude', $this->map->latitudeToSexagesimal($latitude));
476
477 //Add longitude
478 //XXX: not supported by imagick :'(
479 $image->setImageProperty('exif:GPSLongitude', $this->map->longitudeToSexagesimal($longitude));
480
481 //Add description
482 //XXX: not supported by imagick :'(
483 #$image->setImageProperty('exif:Description', $caption);
484
485 //Set progressive jpeg
486 $image->setInterlaceScheme(\Imagick::INTERLACE_PLANE);
487
488 //Set compression quality
489 //TODO: ajust that
490 $image->setImageCompressionQuality(70);
491
492 //Save image
493 if (!$image->writeImage($map)) {
494 //Throw error
495 throw new \Exception(sprintf('Unable to write image "%s"', $path));
496 }
497
498 //Set mtime
499 $mtime = stat($map)['mtime'];
500 }
501
502 //Read map from cache
503 $response = new BinaryFileResponse($map);
504
505 //Set file name
506 $response->setContentDisposition(HeaderUtils::DISPOSITION_INLINE, 'multimap-'.$latitude.','.$longitude.'-'.$zoom.'-'.$width.'x'.$height.'.jpeg');
507
508 //Set etag
509 $response->setEtag(md5(serialize([$updated, $latitude, $longitude, $zoom, $width, $height])));
510
511 //Set last modified
512 $response->setLastModified(\DateTime::createFromFormat('U', strval($mtime)));
513
514 //Set as public
515 $response->setPublic();
516
517 //Return 304 response if not modified
518 $response->isNotModified($request);
519
520 //Return response
521 return $response;
522 }
523 }