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