3 /* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */
10 * Copyright © 2006-2023 The Galette Team
12 * This file is part of Galette (http://galette.tuxfamily.org).
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.
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.
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/>.
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
37 namespace Galette\Core
;
40 use Slim\Psr7\Response
;
43 use Galette\Entity\Adherent
;
44 use Galette\Repository\Members
;
45 use Galette\IO\FileInterface
;
46 use Galette\IO\FileTrait
;
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
60 class Picture
implements FileInterface
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
;
72 protected $tbl_prefix = '';
78 protected $optimal_height;
79 protected $optimal_width;
83 protected $has_picture = false;
84 protected $store_path = GALETTE_PHOTOS_PATH
;
85 protected $max_width = 200;
86 protected $max_height = 200;
90 * Default constructor.
92 * @param mixed|null $id_adh the id of the member
94 public function __construct($id_adh = null)
99 array('jpeg', 'jpg', 'png', 'gif', 'webp'),
101 'jpg' => 'image/jpeg',
102 'png' => 'image/png',
103 'gif' => 'image/gif',
104 'webp' => 'image/webp'
108 // '!==' needed, otherwise ''==0
109 if ($id_adh !== '' && $id_adh !== null) {
111 if (!isset($this->db_id
)) {
112 $this->db_id
= $id_adh;
115 //if file does not exist on the FileSystem, check for it in the database
116 if (!$this->checkFileOnFS()) {
117 if ($this->checkFileInDB()) {
118 $this->has_picture
= true;
121 $this->has_picture
= true;
125 // if we still have no picture, take the default one
126 if (empty($this->file_path
)) {
127 $this->getDefaultPicture();
130 //we should not have an empty file_path, but...
131 if (!empty($this->file_path
)) {
137 * "Magic" function called on unserialize
141 public function __wakeup()
143 //if file has been deleted since we store our object in the session,
144 //we try to retrieve it
145 if (!$this->checkFileOnFS()) {
146 //if file does not exist on the FileSystem,
147 //check for it in the database
148 //$this->checkFileInDB();
150 $this->has_picture
= false;
153 // if we still have no picture, take the default one
154 if (empty($this->file_path
)) {
155 $this->getDefaultPicture();
158 //we should not have an empty file_path, but...
159 if (!empty($this->file_path
)) {
165 * Check if current file is present on the File System
167 * @return boolean true if file is present on FS, false otherwise
169 private function checkFileOnFS()
171 $file_wo_ext = $this->store_path
. $this->id
;
172 if (file_exists($file_wo_ext . '.jpg')) {
173 $this->file_path
= realpath($file_wo_ext . '.jpg');
174 $this->format
= 'jpg';
175 $this->mime
= 'image/jpeg';
177 } elseif (file_exists($file_wo_ext . '.png')) {
178 $this->file_path
= realpath($file_wo_ext . '.png');
179 $this->format
= 'png';
180 $this->mime
= 'image/png';
182 } elseif (file_exists($file_wo_ext . '.gif')) {
183 $this->file_path
= realpath($file_wo_ext . '.gif');
184 $this->format
= 'gif';
185 $this->mime
= 'image/gif';
187 } elseif (file_exists($file_wo_ext . '.webp')) {
188 $this->file_path
= realpath($file_wo_ext . '.webp');
189 $this->format
= 'webp';
190 $this->mime
= 'image/webp';
197 * Check if current file is present in the database,
198 * and copy it to the File System
200 * @return boolean true if file is present in the DB, false otherwise
202 private function checkFileInDB()
207 $select = $this->getCheckFileQuery();
208 $results = $zdb->execute($select);
209 $pic = $results->current();
212 // we must regenerate the picture file
213 $file_wo_ext = $this->store_path
. $this->id
;
215 $file_wo_ext . '.' . $pic->format
,
219 $this->format
= $pic->format
;
220 switch ($this->format
) {
222 $this->mime
= 'image/jpeg';
225 $this->mime
= 'image/png';
228 $this->mime
= 'image/gif';
231 $this->mime
= 'image/webp';
234 $this->file_path
= realpath($file_wo_ext . '.' . $this->format
);
237 } catch (Throwable
$e) {
244 * Returns the relevant query to check if picture exists in database.
246 * @return string SELECT query
248 protected function getCheckFileQuery()
251 $class = get_class($this);
253 $select = $zdb->select($this->tbl_prefix
. $class::TABLE
);
260 $select->where(array($class::PK
=> $this->db_id
));
265 * Gets the default picture to show, anyways
269 protected function getDefaultPicture()
271 $this->file_path
= realpath(_CURRENT_THEME_PATH
. 'images/default.png');
272 $this->format
= 'png';
273 $this->mime
= 'image/png';
274 $this->has_picture
= false;
282 private function setSizes()
284 list($width, $height) = getimagesize($this->file_path
);
285 $this->height
= $height;
286 $this->width
= $width;
287 $this->optimal_height
= $height;
288 $this->optimal_width
= $width;
290 if ($this->height
> $this->width
) {
291 if ($this->height
> $this->max_height
) {
292 $ratio = $this->max_height
/ $this->height
;
293 $this->optimal_height
= $this->max_height
;
294 $this->optimal_width
= $this->width
* $ratio;
297 if ($this->width
> $this->max_width
) {
298 $ratio = $this->max_width
/ $this->width
;
299 $this->optimal_width
= $this->max_width
;
300 $this->optimal_height
= $this->height
* $ratio;
306 * Get image file content
310 public function getContents()
312 readfile($this->file_path
);
316 * Set header and displays the picture.
318 * @param Response $response Response
320 * @return object the binary file
322 public function display(Response
$response)
324 $response = $response->withHeader('Content-Type', $this->mime
)
325 ->withHeader('Content-Transfer-Encoding', 'binary')
326 ->withHeader('Expires', '0')
327 ->withHeader('Cache-Control', 'must-revalidate')
328 ->withHeader('Pragma', 'public');
330 $stream = fopen('php://memory', 'r+');
331 fwrite($stream, file_get_contents($this->file_path
));
334 return $response->withBody(new \Slim\Psr7\
Stream($stream));
338 * Deletes a picture, from both database and filesystem
340 * @param boolean $transaction Whether to use a transaction here or not
342 * @return boolean true if image was successfully deleted, false otherwise
344 public function delete($transaction = true)
347 $class = get_class($this);
350 if ($transaction === true) {
351 $zdb->connection
->beginTransaction();
354 $delete = $zdb->delete($this->tbl_prefix
. $class::TABLE
);
355 $delete->where([$class::PK
=> $this->db_id
]);
356 $del = $zdb->execute($delete);
358 if (!$del->count() > 0) {
360 'Unable to remove picture database entry for ' . $this->db_id
,
363 //it may be possible image is missing in the database.
364 //let's try to remove file anyway.
367 $file_wo_ext = $this->store_path
. $this->id
;
369 // take back default picture
370 $this->getDefaultPicture();
376 if (file_exists($file_wo_ext . '.jpg')) {
377 //return unlink($file_wo_ext . '.jpg');
378 $_file = $file_wo_ext . '.jpg';
379 $success = unlink($_file);
380 } elseif (file_exists($file_wo_ext . '.png')) {
381 //return unlink($file_wo_ext . '.png');
382 $_file = $file_wo_ext . '.png';
383 $success = unlink($_file);
384 } elseif (file_exists($file_wo_ext . '.gif')) {
385 //return unlink($file_wo_ext . '.gif');
386 $_file = $file_wo_ext . '.gif';
387 $success = unlink($_file);
388 } elseif (file_exists($file_wo_ext . '.webp')) {
389 //return unlink($file_wo_ext . '.webp');
390 $_file = $file_wo_ext . '.webp';
391 $success = unlink($_file);
394 if ($_file !== null && $success !== true) {
395 //unable to remove file that exists!
396 if ($transaction === true) {
397 $zdb->connection
->rollBack();
400 'The file ' . $_file .
401 ' was found on the disk but cannot be removed.',
406 if ($transaction === true) {
407 $zdb->connection
->commit();
409 $this->has_picture
= false;
412 } catch (Throwable
$e) {
413 if ($transaction === true) {
414 $zdb->connection
->rollBack();
417 'An error occurred attempting to delete picture ' . $this->db_id
.
418 'from database | ' . $e->getMessage(),
426 * Stores an image on the disk and in the database
428 * @param object $file The uploaded file
429 * @param boolean $ajax If the image cames from an ajax call (dnd)
430 * @param array $cropping Cropping properties
434 public function store($file, $ajax = false, $cropping = null)
436 /** TODO: fix max size (by preferences ?) */
439 $class = get_class($this);
441 $name = $file['name'];
442 $tmpfile = $file['tmp_name'];
444 //First, does the file have a valid name?
445 $reg = "/^([^" . implode('', $this->bad_chars
) . "]+)\.(" .
446 implode('|', $this->allowed_extensions
) . ")$/i";
447 if (preg_match($reg, $name, $matches)) {
449 '[' . $class . '] Filename and extension are OK, proceed.',
452 $extension = strtolower($matches[2]);
453 if ($extension == 'jpeg') {
454 //jpeg is an allowed extension,
455 //but we change it to jpg to reduce further tests :)
459 $erreg = "/^([^" . implode('', $this->bad_chars
) . "]+)\.(.*)/i";
460 $m = preg_match($erreg, $name, $errmatches);
462 $err_msg = '[' . $class . '] ';
464 //ok, we got a good filename and an extension. Extension is bad :)
465 $err_msg .= 'Invalid extension for file ' . $name . '.';
466 $ret = self
::INVALID_EXTENSION
;
468 $err_msg = 'Invalid filename `' . $name . '` (Tip: ';
469 $err_msg .= preg_replace(
471 htmlentities($this->getBadChars()),
472 "file name should not contain any of: %s). "
474 $ret = self
::INVALID_FILENAME
;
484 //Second, let's check file size
485 if ($file['size'] > ($this->maxlenght
* 1024)) {
487 '[' . $class . '] File is too big (' . ($file['size'] * 1024) .
488 'Ko for maximum authorized ' . ($this->maxlenght
* 1024) .
492 return self
::FILE_TOO_BIG
;
494 Analog
::log('[' . $class . '] Filesize is OK, proceed', Analog
::DEBUG
);
497 $current = getimagesize($tmpfile);
499 if (!in_array($current['mime'], $this->allowed_mimes
)) {
501 '[' . $class . '] Mimetype `' . $current['mime'] . '` not allowed',
504 return self
::MIME_NOT_ALLOWED
;
507 '[' . $class . '] Mimetype is allowed, proceed',
512 // Source image must have minimum dimensions to match the cropping process requirements
513 // and ensure the final picture will fit the maximum allowed resizing dimensions.
514 if (isset($cropping['ratio']) && isset($cropping['focus'])) {
515 if ($current[0] < $this->mincropsize ||
$current[1] < $this->mincropsize
) {
516 $min_current = min($current[0], $current[1]);
518 '[' . $class . '] Image is too small. The minimum image side size allowed is ' .
519 $this->mincropsize
. 'px, but current is ' . $min_current . 'px.',
522 return self
::IMAGE_TOO_SMALL
;
524 Analog
::log('[' . $class . '] Image dimensions are OK, proceed', Analog
::DEBUG
);
530 $new_file = $this->store_path
.
531 $this->id
. '.' . $extension;
532 if ($ajax === true) {
533 rename($tmpfile, $new_file);
535 move_uploaded_file($tmpfile, $new_file);
538 // current[0] gives width ; current[1] gives height
539 if ($current[0] > $this->max_width ||
$current[1] > $this->max_height
) {
540 /** FIXME: what if image cannot be resized?
541 Should'nt we want to stop the process here? */
542 $this->resizeImage($new_file, $extension, null, $cropping);
545 return $this->storeInDb($zdb, $this->db_id
, $new_file, $extension);
549 * Stores an image in the database
551 * @param Db $zdb Database instance
552 * @param int $id Member ID
553 * @param string $file File path on disk
554 * @param string $ext File extension
558 private function storeInDb(Db
$zdb, $id, $file, $ext)
560 $f = fopen($file, 'r');
562 while ($r = fread($f, 8192)) {
567 $class = get_class($this);
570 $zdb->connection
->beginTransaction();
571 $stmt = $this->insert_stmt
;
573 $insert = $zdb->insert($this->tbl_prefix
. $class::TABLE
);
576 $class::PK
=> ':' . $class::PK
,
577 'picture' => ':picture',
578 'format' => ':format'
581 $stmt = $zdb->sql
->prepareStatementForSqlObject($insert);
582 $container = $stmt->getParameterContainer();
583 $container->offsetSet(
584 'picture', //'picture',
588 $stmt->setParameterContainer($container);
589 $this->insert_stmt
= $stmt;
595 'picture' => $picture,
599 $zdb->connection
->commit();
600 $this->has_picture
= true;
601 } catch (Throwable
$e) {
602 $zdb->connection
->rollBack();
604 'An error occurred storing picture in database: ' .
608 return self
::SQL_ERROR
;
615 * Check for missing images in database
617 * @param Db $zdb Database instance
621 public function missingInDb(Db
$zdb)
623 $existing_disk = array();
625 //retrieve files on disk
626 if ($handle = opendir($this->store_path
)) {
627 while (false !== ($entry = readdir($handle))) {
628 $reg = "/^(\d+)\.(" .
629 implode('|', $this->allowed_extensions
) . ")$/i";
630 if (preg_match($reg, $entry, $matches)) {
632 $extension = strtolower($matches[2]);
633 if ($extension == 'jpeg') {
634 //jpeg is an allowed extension,
635 //but we change it to jpg to reduce further tests :)
638 $existing_disk[$id] = array(
647 if (count($existing_disk) === 0) {
648 //no image on disk, nothing to do :)
652 //retrieve files in database
653 $class = get_class($this);
654 $select = $zdb->select($this->tbl_prefix
. $class::TABLE
);
656 ->columns(array($class::PK
))
657 ->where
->in($class::PK
, array_keys($existing_disk));
659 $results = $zdb->execute($select);
661 $existing_db = array();
662 foreach ($results as $result) {
663 $existing_db[] = (int)$result[self
::PK
];
666 $existing_diff = array_diff(array_keys($existing_disk), $existing_db);
668 //retrieve valid members ids
669 $members = new Members();
670 $valids = $members->getArrayList(
678 foreach ($valids as $valid) {
679 /** @var ArrayObject $valid */
680 $file = $existing_disk[$valid->id_adh
];
684 $this->store_path
. $file['id'] . '.' . $file['ext'],
690 'Something went wrong opening images directory ' .
698 * Resize and eventually crop the image if it exceeds max allowed sizes
700 * @param string $source The source image
701 * @param string $ext File's extension
702 * @param string $dest The destination image.
703 * If null, we'll use the source image. Defaults to null
704 * @param array $cropping Cropping properties
708 private function resizeImage($source, $ext, $dest = null, $cropping = null)
710 $class = get_class($this);
712 if (!function_exists("gd_info")) {
714 '[' . $class . '] GD is not present - ' .
715 'pictures could not be resized!',
722 $h = $this->max_height
;
723 $w = $this->max_width
;
728 switch (strtolower($ext)) {
730 if (!$gdinfo['JPEG Support']) {
732 '[' . $class . '] GD has no JPEG Support - ' .
733 'pictures could not be resized!',
740 if (!$gdinfo['PNG Support']) {
742 '[' . $class . '] GD has no PNG Support - ' .
743 'pictures could not be resized!',
750 if (!$gdinfo['GIF Create Support']) {
752 '[' . $class . '] GD has no GIF Support - ' .
753 'pictures could not be resized!',
760 if (!$gdinfo['WebP Support']) {
762 '[' . $class . '] GD has no WebP Support - ' .
763 'pictures could not be resized!',
774 list($cur_width, $cur_height, $cur_type, $curattr)
775 = getimagesize($source);
777 $ratio = $cur_width / $cur_height;
779 // Define cropping variables if necessary.
780 $thumb_cropped = false;
781 // Cropping is based on the smallest side of the source in order to
782 // provide as less focusing options as possible if the source doesn't
783 // fit the final ratio (center, top, bottom, left, right).
784 $min_size = min($cur_width, $cur_height);
785 // Cropping dimensions.
786 $crop_width = $min_size;
787 $crop_height = $min_size;
791 if (isset($cropping['ratio']) && isset($cropping['focus'])) {
792 // Calculate cropping dimensions
793 switch ($cropping['ratio']) {
794 case 'portrait_ratio':
795 // Calculate cropping dimensions
797 $crop_height = ceil($crop_width * 4 / 3);
799 $crop_width = ceil($crop_height * 3 / 4);
801 // Calculate resizing dimensions
802 $w = ceil($h * 3 / 4);
804 case 'landscape_ratio':
805 // Calculate cropping dimensions
807 $crop_width = ceil($crop_height * 4 / 3);
809 $crop_height = ceil($crop_width * 3 / 4);
811 // Calculate resizing dimensions
812 $h = ceil($w * 3 / 4);
815 // Calculate focus coordinates
816 switch ($cropping['focus']) {
819 $crop_x = ceil(($cur_width - $crop_width) / 2);
820 } elseif ($ratio == 1) {
821 $crop_x = ceil(($cur_width - $crop_width) / 2);
822 $crop_y = ceil(($cur_height - $crop_height) / 2);
824 $crop_y = ceil(($cur_height - $crop_height) / 2);
828 $crop_x = ceil(($cur_width - $crop_width) / 2);
831 $crop_y = $cur_height - $crop_height;
834 $crop_x = $cur_width - $crop_width;
838 $thumb_cropped = imagecreatetruecolor($crop_width, $crop_height);
840 $ratio = $crop_width / $crop_height;
841 // Otherwise, calculate image size according to the source's ratio.
843 if ($cur_width > $cur_height) {
844 $h = round($w / $ratio);
846 $w = round($h * $ratio);
851 $thumb = imagecreatetruecolor($w, $h);
856 $image = imagecreatefromjpeg($source);
858 if ($thumb_cropped !== false) {
860 imagecopyresampled($thumb_cropped, $image, 0, 0, $crop_x, $crop_y, $cur_width, $cur_height, $cur_width, $cur_height);
862 imagecopyresampled($thumb, $thumb_cropped, 0, 0, 0, 0, $w, $h, $crop_width, $crop_height);
865 imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height);
867 imagejpeg($thumb, $dest);
870 $image = imagecreatefrompng($source);
871 // Turn off alpha blending and set alpha flag. That prevent alpha
872 // transparency to be saved as an arbitrary color (black in my tests)
873 imagealphablending($image, false);
874 imagesavealpha($image, true);
875 imagealphablending($thumb, false);
876 imagesavealpha($thumb, true);
878 if ($thumb_cropped !== false) {
879 imagealphablending($thumb_cropped, false);
880 imagesavealpha($thumb_cropped, true);
882 imagecopyresampled($thumb_cropped, $image, 0, 0, $crop_x, $crop_y, $cur_width, $cur_height, $cur_width, $cur_height);
884 imagecopyresampled($thumb, $thumb_cropped, 0, 0, 0, 0, $w, $h, $crop_width, $crop_height);
887 imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height);
889 imagepng($thumb, $dest);
892 $image = imagecreatefromgif($source);
894 if ($thumb_cropped !== false) {
896 imagecopyresampled($thumb_cropped, $image, 0, 0, $crop_x, $crop_y, $cur_width, $cur_height, $cur_width, $cur_height);
898 imagecopyresampled($thumb, $thumb_cropped, 0, 0, 0, 0, $w, $h, $crop_width, $crop_height);
901 imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height);
903 imagegif($thumb, $dest);
906 $image = imagecreatefromwebp($source);
908 if ($thumb_cropped !== false) {
910 imagecopyresampled($thumb_cropped, $image, 0, 0, $crop_x, $crop_y, $cur_width, $cur_height, $cur_width, $cur_height);
912 imagecopyresampled($thumb, $thumb_cropped, 0, 0, 0, 0, $w, $h, $crop_width, $crop_height);
915 imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height);
917 imagewebp($thumb, $dest);
925 * Returns current file optimal height (resized)
927 * @return int optimal height
929 public function getOptimalHeight()
931 return (int)round($this->optimal_height
, 1);
935 * Returns current file height
937 * @return int current height
939 public function getHeight()
941 return $this->height
;
945 * Returns current file optimal width (resized)
947 * @return int optimal width
949 public function getOptimalWidth()
951 return (int)round($this->optimal_width
, 1);
955 * Returns current file width
957 * @return int current width
959 public function getWidth()
965 * Returns current file format
969 public function getFormat()
971 return $this->format
;
975 * Have we got a picture ?
977 * @return bool True if a picture matches adherent's id, false otherwise
979 public function hasPicture()
981 return $this->has_picture
;
985 * Returns current file full path
987 * @return string full file path
989 public function getPath()
991 return $this->file_path
;
995 * Returns current mime type
999 public function getMime()
1005 * Return textual error message
1007 * @param int $code The error code
1009 * @return string Localized message
1011 public function getErrorMessage($code)
1015 case self
::SQL_ERROR
:
1016 case self
::SQL_BLOB_ERROR
:
1017 $error = _T("An SQL error has occurred.");
1021 if ($error === null) {
1022 $error = $this->getErrorMessageFromCode($code);