]> git.agnieray.net Git - galette.git/blob - galette/lib/Galette/Core/Picture.php
Add phpstan, fix detected errors
[galette.git] / galette / lib / Galette / Core / Picture.php
1 <?php
2
3 /* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */
4
5 /**
6 * Picture handling
7 *
8 * PHP version 5
9 *
10 * Copyright © 2006-2023 The Galette Team
11 *
12 * This file is part of Galette (http://galette.tuxfamily.org).
13 *
14 * Galette is free software: you can redistribute it and/or modify
15 * it under the terms of the GNU General Public License as published by
16 * the Free Software Foundation, either version 3 of the License, or
17 * (at your option) any later version.
18 *
19 * Galette is distributed in the hope that it will be useful,
20 * but WITHOUT ANY WARRANTY; without even the implied warranty of
21 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22 * GNU General Public License for more details.
23 *
24 * You should have received a copy of the GNU General Public License
25 * along with Galette. If not, see <http://www.gnu.org/licenses/>.
26 *
27 * @category Core
28 * @package Galette
29 *
30 * @author Frédéric Jacquot <unknown@unknow.com>
31 * @author Johan Cwiklinski <johan@x-tnd.be>
32 * @copyright 2006-2023 The Galette Team
33 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL License 3.0 or (at your option) any later version
34 * @link http://galette.tuxfamily.org
35 */
36
37 namespace Galette\Core;
38
39 use Throwable;
40 use Analog\Analog;
41 use Galette\Entity\Adherent;
42 use Galette\Repository\Members;
43 use Galette\IO\FileInterface;
44 use Galette\IO\FileTrait;
45
46 /**
47 * Picture handling
48 *
49 * @name Picture
50 * @category Core
51 * @package Galette
52 * @author Frédéric Jacquot <unknown@unknow.com>
53 * @author Johan Cwiklinski <johan@x-tnd.be>
54 * @copyright 2006-2023 The Galette Team
55 * @license http://www.gnu.org/licenses/gpl-3.0.html GPL License 3.0 or (at your option) any later version
56 * @link http://galette.tuxfamily.org
57 */
58 class Picture implements FileInterface
59 {
60 use FileTrait;
61
62 //constants that will not be overrided
63 public const SQL_ERROR = -10;
64 public const SQL_BLOB_ERROR = -11;
65 //constants that can be overrided
66 //(do not use self::CONSTANT, but get_class[$this]::CONSTANT)
67 public const TABLE = 'pictures';
68 public const PK = Adherent::PK;
69
70 protected $tbl_prefix = '';
71
72 protected $id;
73 protected $db_id;
74 protected $height;
75 protected $width;
76 protected $optimal_height;
77 protected $optimal_width;
78 protected $file_path;
79 protected $format;
80 protected $mime;
81 protected $has_picture = false;
82 protected $store_path = GALETTE_PHOTOS_PATH;
83 protected $max_width = 200;
84 protected $max_height = 200;
85 private $insert_stmt;
86
87 /**
88 * Default constructor.
89 *
90 * @param int $id_adh the id of the member
91 */
92 public function __construct($id_adh = '')
93 {
94
95 $this->init(
96 null,
97 array('jpeg', 'jpg', 'png', 'gif'),
98 array(
99 'jpg' => 'image/jpeg',
100 'png' => 'image/png',
101 'gif' => 'image/gif'
102 )
103 );
104
105 // '!==' needed, otherwise ''==0
106 if ($id_adh !== '' && $id_adh !== null) {
107 $this->id = $id_adh;
108 if (!isset($this->db_id)) {
109 $this->db_id = $id_adh;
110 }
111
112 //if file does not exists on the FileSystem, check for it in the database
113 if (!$this->checkFileOnFS()) {
114 if ($this->checkFileInDB()) {
115 $this->has_picture = true;
116 }
117 } else {
118 $this->has_picture = true;
119 }
120 }
121
122 // if we still have no picture, take the default one
123 if (empty($this->file_path)) {
124 $this->getDefaultPicture();
125 }
126
127 //we should not have an empty file_path, but...
128 if (!empty($this->file_path)) {
129 $this->setSizes();
130 }
131 }
132
133 /**
134 * "Magic" function called on unserialize
135 *
136 * @return void
137 */
138 public function __wakeup()
139 {
140 //if file has been deleted since we store our object in the session,
141 //we try to retrieve it
142 if (!$this->checkFileOnFS()) {
143 //if file does not exists on the FileSystem,
144 //check for it in the database
145 //$this->checkFileInDB();
146 } else {
147 $this->has_picture = false;
148 }
149
150 // if we still have no picture, take the default one
151 if (empty($this->file_path)) {
152 $this->getDefaultPicture();
153 }
154
155 //we should not have an empty file_path, but...
156 if (!empty($this->file_path)) {
157 $this->setSizes();
158 }
159 }
160
161 /**
162 * Check if current file is present on the File System
163 *
164 * @return boolean true if file is present on FS, false otherwise
165 */
166 private function checkFileOnFS()
167 {
168 $file_wo_ext = $this->store_path . $this->id;
169 if (file_exists($file_wo_ext . '.jpg')) {
170 $this->file_path = realpath($file_wo_ext . '.jpg');
171 $this->format = 'jpg';
172 $this->mime = 'image/jpeg';
173 return true;
174 } elseif (file_exists($file_wo_ext . '.png')) {
175 $this->file_path = realpath($file_wo_ext . '.png');
176 $this->format = 'png';
177 $this->mime = 'image/png';
178 return true;
179 } elseif (file_exists($file_wo_ext . '.gif')) {
180 $this->file_path = realpath($file_wo_ext . '.gif');
181 $this->format = 'gif';
182 $this->mime = 'image/gif';
183 return true;
184 }
185 return false;
186 }
187
188 /**
189 * Check if current file is present in the database,
190 * and copy it to the File System
191 *
192 * @return boolean true if file is present in the DB, false otherwise
193 */
194 private function checkFileInDB()
195 {
196 global $zdb;
197
198 try {
199 $select = $this->getCheckFileQuery();
200 $results = $zdb->execute($select);
201 $pic = $results->current();
202
203 if ($pic) {
204 // we must regenerate the picture file
205 $file_wo_ext = $this->store_path . $this->id;
206 file_put_contents(
207 $file_wo_ext . '.' . $pic->format,
208 $pic->picture
209 );
210
211 $this->format = $pic->format;
212 switch ($this->format) {
213 case 'jpg':
214 $this->mime = 'image/jpeg';
215 break;
216 case 'png':
217 $this->mime = 'image/png';
218 break;
219 case 'gif':
220 $this->mime = 'image/gif';
221 break;
222 }
223 $this->file_path = realpath($file_wo_ext . '.' . $this->format);
224 return true;
225 }
226 } catch (Throwable $e) {
227 return false;
228 }
229 return false;
230 }
231
232 /**
233 * Returns the relevant query to check if picture exists in database.
234 *
235 * @return string SELECT query
236 */
237 protected function getCheckFileQuery()
238 {
239 global $zdb;
240 $class = get_class($this);
241
242 $select = $zdb->select($this->tbl_prefix . $class::TABLE);
243 $select->columns(
244 array(
245 'picture',
246 'format'
247 )
248 );
249 $select->where(array($class::PK => $this->db_id));
250 return $select;
251 }
252
253 /**
254 * Gets the default picture to show, anyways
255 *
256 * @return void
257 */
258 protected function getDefaultPicture()
259 {
260 $this->file_path = realpath(_CURRENT_THEME_PATH . 'images/default.png');
261 $this->format = 'png';
262 $this->mime = 'image/png';
263 $this->has_picture = false;
264 }
265
266 /**
267 * Set picture sizes
268 *
269 * @return void
270 */
271 private function setSizes()
272 {
273 list($width, $height) = getimagesize($this->file_path);
274 $this->height = $height;
275 $this->width = $width;
276 $this->optimal_height = $height;
277 $this->optimal_width = $width;
278
279 if ($this->height > $this->width) {
280 if ($this->height > $this->max_height) {
281 $ratio = $this->max_height / $this->height;
282 $this->optimal_height = $this->max_height;
283 $this->optimal_width = $this->width * $ratio;
284 }
285 } else {
286 if ($this->width > $this->max_width) {
287 $ratio = $this->max_width / $this->width;
288 $this->optimal_width = $this->max_width;
289 $this->optimal_height = $this->height * $ratio;
290 }
291 }
292 }
293
294 /**
295 * Get image file contents
296 *
297 * @return mixed
298 */
299 public function getContents()
300 {
301 readfile($this->file_path);
302 }
303
304 /**
305 * Set header and displays the picture.
306 *
307 * @param Response $response Reponse
308 *
309 * @return object the binary file
310 */
311 public function display(\Slim\Psr7\Response $response)
312 {
313 $response = $response->withHeader('Content-Type', $this->mime)
314 ->withHeader('Content-Transfer-Encoding', 'binary')
315 ->withHeader('Expires', '0')
316 ->withHeader('Cache-Control', 'must-revalidate')
317 ->withHeader('Pragma', 'public');
318
319 $stream = fopen('php://memory', 'r+');
320 fwrite($stream, file_get_contents($this->file_path));
321 rewind($stream);
322
323 return $response->withBody(new \Slim\Psr7\Stream($stream));
324 }
325
326 /**
327 * Deletes a picture, from both database and filesystem
328 *
329 * @param boolean $transaction Whether to use a transaction here or not
330 *
331 * @return boolean true if image was successfully deleted, false otherwise
332 */
333 public function delete($transaction = true)
334 {
335 global $zdb;
336 $class = get_class($this);
337
338 try {
339 if ($transaction === true) {
340 $zdb->connection->beginTransaction();
341 }
342
343 $delete = $zdb->delete($this->tbl_prefix . $class::TABLE);
344 $delete->where([$class::PK => $this->db_id]);
345 $del = $zdb->execute($delete);
346
347 if (!$del->count() > 0) {
348 Analog::log(
349 'Unable to remove picture database entry for ' . $this->db_id,
350 Analog::ERROR
351 );
352 //it may be possible image is missing in the database.
353 //let's try to remove file anyway.
354 }
355
356 $file_wo_ext = $this->store_path . $this->id;
357
358 // take back default picture
359 $this->getDefaultPicture();
360 // fix sizes
361 $this->setSizes();
362
363 $success = false;
364 $_file = null;
365 if (file_exists($file_wo_ext . '.jpg')) {
366 //return unlink($file_wo_ext . '.jpg');
367 $_file = $file_wo_ext . '.jpg';
368 $success = unlink($_file);
369 } elseif (file_exists($file_wo_ext . '.png')) {
370 //return unlink($file_wo_ext . '.png');
371 $_file = $file_wo_ext . '.png';
372 $success = unlink($_file);
373 } elseif (file_exists($file_wo_ext . '.gif')) {
374 //return unlink($file_wo_ext . '.gif');
375 $_file = $file_wo_ext . '.gif';
376 $success = unlink($_file);
377 }
378
379 if ($_file !== null && $success !== true) {
380 //unable to remove file that exists!
381 if ($transaction === true) {
382 $zdb->connection->rollBack();
383 }
384 Analog::log(
385 'The file ' . $_file .
386 ' was found on the disk but cannot be removed.',
387 Analog::ERROR
388 );
389 return false;
390 } else {
391 if ($transaction === true) {
392 $zdb->connection->commit();
393 }
394 $this->has_picture = false;
395 return true;
396 }
397 } catch (Throwable $e) {
398 if ($transaction === true) {
399 $zdb->connection->rollBack();
400 }
401 Analog::log(
402 'An error occurred attempting to delete picture ' . $this->db_id .
403 'from database | ' . $e->getMessage(),
404 Analog::ERROR
405 );
406 return false;
407 }
408 }
409
410 /**
411 * Stores an image on the disk and in the database
412 *
413 * @param object $file the uploaded file
414 * @param boolean $ajax If the image cames from an ajax call (dnd)
415 *
416 * @return true|false result of the storage process
417 */
418 public function store($file, $ajax = false)
419 {
420 /** TODO: fix max size (by preferences ?) */
421 global $zdb;
422
423 $class = get_class($this);
424
425 $name = $file['name'];
426 $tmpfile = $file['tmp_name'];
427
428 //First, does the file have a valid name?
429 $reg = "/^([^" . implode('', $this->bad_chars) . "]+)\.(" .
430 implode('|', $this->allowed_extensions) . ")$/i";
431 if (preg_match($reg, $name, $matches)) {
432 Analog::log(
433 '[' . $class . '] Filename and extension are OK, proceed.',
434 Analog::DEBUG
435 );
436 $extension = strtolower($matches[2]);
437 if ($extension == 'jpeg') {
438 //jpeg is an allowed extension,
439 //but we change it to jpg to reduce further tests :)
440 $extension = 'jpg';
441 }
442 } else {
443 $erreg = "/^([^" . implode('', $this->bad_chars) . "]+)\.(.*)/i";
444 $m = preg_match($erreg, $name, $errmatches);
445
446 $err_msg = '[' . $class . '] ';
447 if ($m == 1) {
448 //ok, we got a good filename and an extension. Extension is bad :)
449 $err_msg .= 'Invalid extension for file ' . $name . '.';
450 $ret = self::INVALID_EXTENSION;
451 } else {
452 $err_msg = 'Invalid filename `' . $name . '` (Tip: ';
453 $err_msg .= preg_replace(
454 '|%s|',
455 htmlentities($this->getBadChars()),
456 "file name should not contain any of: %s). "
457 );
458 $ret = self::INVALID_FILENAME;
459 }
460
461 Analog::log(
462 $err_msg,
463 Analog::ERROR
464 );
465 return $ret;
466 }
467
468 //Second, let's check file size
469 if ($file['size'] > ($this->maxlenght * 1024)) {
470 Analog::log(
471 '[' . $class . '] File is too big (' . ($file['size'] * 1024) .
472 'Ko for maximum authorized ' . ($this->maxlenght * 1024) .
473 'Ko',
474 Analog::ERROR
475 );
476 return self::FILE_TOO_BIG;
477 } else {
478 Analog::log('[' . $class . '] Filesize is OK, proceed', Analog::DEBUG);
479 }
480
481 $current = getimagesize($tmpfile);
482
483 if (!in_array($current['mime'], $this->allowed_mimes)) {
484 Analog::log(
485 '[' . $class . '] Mimetype `' . $current['mime'] . '` not allowed',
486 Analog::ERROR
487 );
488 return self::MIME_NOT_ALLOWED;
489 } else {
490 Analog::log(
491 '[' . $class . '] Mimetype is allowed, proceed',
492 Analog::DEBUG
493 );
494 }
495
496 $this->delete();
497
498 $new_file = $this->store_path .
499 $this->id . '.' . $extension;
500 if ($ajax === true) {
501 rename($tmpfile, $new_file);
502 } else {
503 move_uploaded_file($tmpfile, $new_file);
504 }
505
506 // current[0] gives width ; current[1] gives height
507 if ($current[0] > $this->max_width || $current[1] > $this->max_height) {
508 /** FIXME: what if image cannot be resized?
509 Should'nt we want to stop the process here? */
510 $this->resizeImage($new_file, $extension);
511 }
512
513 return $this->storeInDb($zdb, $this->db_id, $new_file, $extension);
514 }
515
516 /**
517 * Stores an image in the database
518 *
519 * @param Db $zdb Database instance
520 * @param int $id Member ID
521 * @param string $file File path on disk
522 * @param string $ext File extension
523 *
524 * @return boolean
525 */
526 private function storeInDb(Db $zdb, $id, $file, $ext)
527 {
528 $f = fopen($file, 'r');
529 $picture = '';
530 while ($r = fread($f, 8192)) {
531 $picture .= $r;
532 }
533 fclose($f);
534
535 $class = get_class($this);
536
537 try {
538 $zdb->connection->beginTransaction();
539 $stmt = $this->insert_stmt;
540 if ($stmt == null) {
541 $insert = $zdb->insert($this->tbl_prefix . $class::TABLE);
542 $insert->values(
543 array(
544 $class::PK => ':' . $class::PK,
545 'picture' => ':picture',
546 'format' => ':format'
547 )
548 );
549 $stmt = $zdb->sql->prepareStatementForSqlObject($insert);
550 $container = $stmt->getParameterContainer();
551 $container->offsetSet(
552 'picture', //'picture',
553 ':picture',
554 $container::TYPE_LOB
555 );
556 $stmt->setParameterContainer($container);
557 $this->insert_stmt = $stmt;
558 }
559
560 $stmt->execute(
561 array(
562 $class::PK => $id,
563 'picture' => $picture,
564 'format' => $ext
565 )
566 );
567 $zdb->connection->commit();
568 $this->has_picture = true;
569 } catch (Throwable $e) {
570 $zdb->connection->rollBack();
571 Analog::log(
572 'An error occurred storing picture in database: ' .
573 $e->getMessage(),
574 Analog::ERROR
575 );
576 return self::SQL_ERROR;
577 }
578
579 return true;
580 }
581
582 /**
583 * Check for missing images in database
584 *
585 * @param Db $zdb Database instance
586 *
587 * @return void
588 */
589 public function missingInDb(Db $zdb)
590 {
591 $existing_disk = array();
592
593 //retrieve files on disk
594 if ($handle = opendir($this->store_path)) {
595 while (false !== ($entry = readdir($handle))) {
596 $reg = "/^(\d+)\.(" .
597 implode('|', $this->allowed_extensions) . ")$/i";
598 if (preg_match($reg, $entry, $matches)) {
599 $id = $matches[1];
600 $extension = strtolower($matches[2]);
601 if ($extension == 'jpeg') {
602 //jpeg is an allowed extension,
603 //but we change it to jpg to reduce further tests :)
604 $extension = 'jpg';
605 }
606 $existing_disk[$id] = array(
607 'name' => $entry,
608 'id' => $id,
609 'ext' => $extension
610 );
611 }
612 }
613 closedir($handle);
614
615 if (count($existing_disk) === 0) {
616 //no image on disk, nothing to do :)
617 return;
618 }
619
620 //retrieve files in database
621 $class = get_class($this);
622 $select = $zdb->select($this->tbl_prefix . $class::TABLE);
623 $select
624 ->columns(array($class::PK))
625 ->where->in($class::PK, array_keys($existing_disk));
626
627 $results = $zdb->execute($select);
628
629 $existing_db = array();
630 foreach ($results as $result) {
631 $existing_db[] = (int)$result[self::PK];
632 }
633
634 $existing_diff = array_diff(array_keys($existing_disk), $existing_db);
635
636 //retrieve valid members ids
637 $members = new Members();
638 $valids = $members->getArrayList(
639 $existing_diff,
640 null,
641 false,
642 false,
643 array(self::PK)
644 );
645
646 foreach ($valids as $valid) {
647 $file = $existing_disk[$valid->id_adh];
648 $this->storeInDb(
649 $zdb,
650 $file['id'],
651 $this->store_path . $file['id'] . '.' . $file['ext'],
652 $file['ext']
653 );
654 }
655 } else {
656 Analog::log(
657 'Something went wrong opening images directory ' .
658 $this->store_path,
659 Analog::ERROR
660 );
661 }
662 }
663
664 /**
665 * Resize the image if it exceed max allowed sizes
666 *
667 * @param string $source the source image
668 * @param string $ext file's extension
669 * @param string $dest the destination image.
670 * If null, we'll use the source image. Defaults to null
671 *
672 * @return void
673 */
674 private function resizeImage($source, $ext, $dest = null)
675 {
676 $class = get_class($this);
677
678 if (function_exists("gd_info")) {
679 $gdinfo = gd_info();
680 $h = $this->max_height;
681 $w = $this->max_width;
682 if ($dest == null) {
683 $dest = $source;
684 }
685
686 switch (strtolower($ext)) {
687 case 'jpg':
688 if (!$gdinfo['JPEG Support']) {
689 Analog::log(
690 '[' . $class . '] GD has no JPEG Support - ' .
691 'pictures could not be resized!',
692 Analog::ERROR
693 );
694 return false;
695 }
696 break;
697 case 'png':
698 if (!$gdinfo['PNG Support']) {
699 Analog::log(
700 '[' . $class . '] GD has no PNG Support - ' .
701 'pictures could not be resized!',
702 Analog::ERROR
703 );
704 return false;
705 }
706 break;
707 case 'gif':
708 if (!$gdinfo['GIF Create Support']) {
709 Analog::log(
710 '[' . $class . '] GD has no GIF Support - ' .
711 'pictures could not be resized!',
712 Analog::ERROR
713 );
714 return false;
715 }
716 break;
717 default:
718 return false;
719 }
720
721 list($cur_width, $cur_height, $cur_type, $curattr)
722 = getimagesize($source);
723
724 $ratio = $cur_width / $cur_height;
725
726 // calculate image size according to ratio
727 if ($cur_width > $cur_height) {
728 $h = round($w / $ratio);
729 } else {
730 $w = round($h * $ratio);
731 }
732
733 $thumb = imagecreatetruecolor($w, $h);
734 switch ($ext) {
735 case 'jpg':
736 $image = imagecreatefromjpeg($source);
737 imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height);
738 imagejpeg($thumb, $dest);
739 break;
740 case 'png':
741 $image = imagecreatefrompng($source);
742 // Turn off alpha blending and set alpha flag. That prevent alpha
743 // transparency to be saved as an arbitrary color (black in my tests)
744 imagealphablending($thumb, false);
745 imagealphablending($image, false);
746 imagesavealpha($thumb, true);
747 imagesavealpha($image, true);
748 imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height);
749 imagepng($thumb, $dest);
750 break;
751 case 'gif':
752 $image = imagecreatefromgif($source);
753 imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height);
754 imagegif($thumb, $dest);
755 break;
756 }
757 } else {
758 Analog::log(
759 '[' . $class . '] GD is not present - ' .
760 'pictures could not be resized!',
761 Analog::ERROR
762 );
763 }
764 }
765
766 /**
767 * Returns current file optimal height (resized)
768 *
769 * @return int optimal height
770 */
771 public function getOptimalHeight()
772 {
773 return (int)round($this->optimal_height, 1);
774 }
775
776 /**
777 * Returns current file height
778 *
779 * @return int current height
780 */
781 public function getHeight()
782 {
783 return $this->height;
784 }
785
786 /**
787 * Returns current file optimal width (resized)
788 *
789 * @return int optimal width
790 */
791 public function getOptimalWidth()
792 {
793 return (int)round($this->optimal_width, 1);
794 }
795
796 /**
797 * Returns current file width
798 *
799 * @return int current width
800 */
801 public function getWidth()
802 {
803 return $this->width;
804 }
805
806 /**
807 * Returns current file format
808 *
809 * @return string
810 */
811 public function getFormat()
812 {
813 return $this->format;
814 }
815
816 /**
817 * Have we got a picture ?
818 *
819 * @return bool True if a picture matches adherent's id, false otherwise
820 */
821 public function hasPicture()
822 {
823 return $this->has_picture;
824 }
825
826 /**
827 * Returns current file full path
828 *
829 * @return string full file path
830 */
831 public function getPath()
832 {
833 return $this->file_path;
834 }
835
836 /**
837 * Returns current mime type
838 *
839 * @return string
840 */
841 public function getMime()
842 {
843 return $this->mime;
844 }
845
846 /**
847 * Return textual error message
848 *
849 * @param int $code The error code
850 *
851 * @return string Localized message
852 */
853 public function getErrorMessage($code)
854 {
855 $error = null;
856 switch ($code) {
857 case self::SQL_ERROR:
858 case self::SQL_BLOB_ERROR:
859 $error = _T("An SQL error has occurred.");
860 break;
861 }
862
863 if ($error === null) {
864 $error = $this->getErrorMessageFromCode($code);
865 }
866
867 return $error;
868 }
869 }