4 * Copyright © 2003-2024 The Galette Team
6 * This file is part of Galette (https://galette.eu).
8 * Galette is free software: you can redistribute it and/or modify
9 * it under the terms of the GNU General Public License as published by
10 * the Free Software Foundation, either version 3 of the License, or
11 * (at your option) any later version.
13 * Galette is distributed in the hope that it will be useful,
14 * but WITHOUT ANY WARRANTY; without even the implied warranty of
15 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 * GNU General Public License for more details.
18 * You should have received a copy of the GNU General Public License
19 * along with Galette. If not, see <http://www.gnu.org/licenses/>.
22 namespace Galette\Core
;
25 use Laminas\Db\Adapter\Driver\StatementInterface
;
26 use Laminas\Db\Sql\Select
;
27 use Slim\Psr7\Response
;
30 use Galette\Entity\Adherent
;
31 use Galette\Repository\Members
;
32 use Galette\IO\FileInterface
;
33 use Galette\IO\FileTrait
;
38 * @author Frédéric Jacquot <gna@logeek.com>
39 * @author Johan Cwiklinski <johan@x-tnd.be>
41 class Picture
implements FileInterface
45 //constants that will not be overridden
46 public const SQL_ERROR
= -10;
47 public const SQL_BLOB_ERROR
= -11;
48 //constants that can be overrided
49 //(do not use self::CONSTANT, but get_class[$this]::CONSTANT)
50 public const TABLE
= 'pictures';
51 public const PK
= Adherent
::PK
;
53 protected string $tbl_prefix = '';
55 protected string|
int $id;
57 protected int $height;
59 protected int $optimal_height;
60 protected int $optimal_width;
61 protected string $file_path;
62 protected string $format;
63 protected string $mime;
64 protected bool $has_picture = false;
65 protected string $store_path = GALETTE_PHOTOS_PATH
;
66 protected int $max_width = 200;
67 protected int $max_height = 200;
68 private StatementInterface
$insert_stmt;
71 * Default constructor.
73 * @param mixed|null $id_adh the id of the member
75 public function __construct($id_adh = null)
80 array('jpeg', 'jpg', 'png', 'gif', 'webp'),
82 'jpg' => 'image/jpeg',
85 'webp' => 'image/webp'
89 // '!==' needed, otherwise ''==0
90 if ($id_adh !== '' && $id_adh !== null) {
92 if (!isset($this->db_id
)) {
93 $this->db_id
= $id_adh;
96 //if file does not exist on the FileSystem, check for it in the database
97 if (!$this->checkFileOnFS()) {
98 if ($this->checkFileInDB()) {
99 $this->has_picture
= true;
102 $this->has_picture
= true;
106 // if we still have no picture, take the default one
107 if (empty($this->file_path
)) {
108 $this->getDefaultPicture();
111 //we should not have an empty file_path, but...
112 if (!empty($this->file_path
)) {
118 * "Magic" function called on unserialize
122 public function __wakeup(): void
124 //if file has been deleted since we store our object in the session,
125 //we try to retrieve it
126 if (isset($this->id
) && !$this->checkFileOnFS()) {
127 //if file does not exist on the FileSystem,
128 //check for it in the database
129 //$this->checkFileInDB();
131 $this->has_picture
= false;
134 // if we still have no picture, take the default one
135 if (empty($this->file_path
)) {
136 $this->getDefaultPicture();
139 //we should not have an empty file_path, but...
140 if (!empty($this->file_path
)) {
146 * Check if current file is present on the File System
148 * @return boolean true if file is present on FS, false otherwise
150 private function checkFileOnFS(): bool
152 $file_wo_ext = $this->store_path
. $this->id
;
153 if (file_exists($file_wo_ext . '.jpg')) {
154 $this->file_path
= realpath($file_wo_ext . '.jpg');
155 $this->format
= 'jpg';
156 $this->mime
= 'image/jpeg';
158 } elseif (file_exists($file_wo_ext . '.png')) {
159 $this->file_path
= realpath($file_wo_ext . '.png');
160 $this->format
= 'png';
161 $this->mime
= 'image/png';
163 } elseif (file_exists($file_wo_ext . '.gif')) {
164 $this->file_path
= realpath($file_wo_ext . '.gif');
165 $this->format
= 'gif';
166 $this->mime
= 'image/gif';
168 } elseif (file_exists($file_wo_ext . '.webp')) {
169 $this->file_path
= realpath($file_wo_ext . '.webp');
170 $this->format
= 'webp';
171 $this->mime
= 'image/webp';
178 * Check if current file is present in the database,
179 * and copy it to the File System
181 * @return boolean true if file is present in the DB, false otherwise
183 private function checkFileInDB(): bool
188 $select = $this->getCheckFileQuery();
189 $results = $zdb->execute($select);
190 $pic = $results->current();
193 // we must regenerate the picture file
194 $file_wo_ext = $this->store_path
. $this->id
;
196 $file_wo_ext . '.' . $pic->format
,
200 $this->format
= $pic->format
;
201 switch ($this->format
) {
203 $this->mime
= 'image/jpeg';
206 $this->mime
= 'image/png';
209 $this->mime
= 'image/gif';
212 $this->mime
= 'image/webp';
215 $this->file_path
= realpath($file_wo_ext . '.' . $this->format
);
218 } catch (Throwable
$e) {
225 * Returns the relevant query to check if picture exists in database.
227 * @return Select SELECT query
229 protected function getCheckFileQuery(): Select
232 $class = get_class($this);
234 $select = $zdb->select($this->tbl_prefix
. $class::TABLE
);
241 $select->where(array($class::PK
=> $this->db_id
));
246 * Gets the default picture to show, anyway
250 protected function getDefaultPicture(): void
252 $this->file_path
= realpath(_CURRENT_THEME_PATH
. 'images/default.png');
253 $this->format
= 'png';
254 $this->mime
= 'image/png';
255 $this->has_picture
= false;
263 private function setSizes(): void
265 list($width, $height) = getimagesize($this->file_path
);
266 $this->height
= $height;
267 $this->width
= $width;
268 $this->optimal_height
= $height;
269 $this->optimal_width
= $width;
271 if ($this->height
> $this->width
) {
272 if ($this->height
> $this->max_height
) {
273 $ratio = $this->max_height
/ $this->height
;
274 $this->optimal_height
= $this->max_height
;
275 $this->optimal_width
= (int)($this->width
* $ratio);
278 if ($this->width
> $this->max_width
) {
279 $ratio = $this->max_width
/ $this->width
;
280 $this->optimal_width
= $this->max_width
;
281 $this->optimal_height
= (int)($this->height
* $ratio);
287 * Get image file contents
291 public function getContents()
293 readfile($this->file_path
);
297 * Set header and displays the picture.
299 * @param Response $response Response
301 * @return Response the binary file
303 public function display(Response
$response): Response
305 $response = $response->withHeader('Content-Type', $this->mime
)
306 ->withHeader('Content-Transfer-Encoding', 'binary')
307 ->withHeader('Expires', '0')
308 ->withHeader('Cache-Control', 'must-revalidate')
309 ->withHeader('Pragma', 'public');
311 $stream = fopen('php://memory', 'r+');
312 fwrite($stream, file_get_contents($this->file_path
));
315 return $response->withBody(new \Slim\Psr7\
Stream($stream));
319 * Deletes a picture, from both database and filesystem
321 * @param boolean $transaction Whether to use a transaction here or not
323 * @return boolean true if image was successfully deleted, false otherwise
325 public function delete(bool $transaction = true): bool
328 $class = get_class($this);
331 if ($transaction === true) {
332 $zdb->connection
->beginTransaction();
335 $delete = $zdb->delete($this->tbl_prefix
. $class::TABLE
);
336 $delete->where([$class::PK
=> $this->db_id
]);
337 $del = $zdb->execute($delete);
339 if (!$del->count() > 0) {
341 'Unable to remove picture database entry for ' . $this->db_id
,
344 //it may be possible image is missing in the database.
345 //let's try to remove file anyway.
348 $file_wo_ext = $this->store_path
. $this->id
;
350 // take back default picture
351 $this->getDefaultPicture();
357 if (file_exists($file_wo_ext . '.jpg')) {
358 //return unlink($file_wo_ext . '.jpg');
359 $_file = $file_wo_ext . '.jpg';
360 $success = unlink($_file);
361 } elseif (file_exists($file_wo_ext . '.png')) {
362 //return unlink($file_wo_ext . '.png');
363 $_file = $file_wo_ext . '.png';
364 $success = unlink($_file);
365 } elseif (file_exists($file_wo_ext . '.gif')) {
366 //return unlink($file_wo_ext . '.gif');
367 $_file = $file_wo_ext . '.gif';
368 $success = unlink($_file);
369 } elseif (file_exists($file_wo_ext . '.webp')) {
370 //return unlink($file_wo_ext . '.webp');
371 $_file = $file_wo_ext . '.webp';
372 $success = unlink($_file);
375 if ($_file !== null && $success !== true) {
376 //unable to remove file that exists!
377 if ($transaction === true) {
378 $zdb->connection
->rollBack();
381 'The file ' . $_file .
382 ' was found on the disk but cannot be removed.',
387 if ($transaction === true) {
388 $zdb->connection
->commit();
390 $this->has_picture
= false;
393 } catch (Throwable
$e) {
394 if ($transaction === true) {
395 $zdb->connection
->rollBack();
398 'An error occurred attempting to delete picture ' . $this->db_id
.
399 'from database | ' . $e->getMessage(),
407 * Stores an image on the disk and in the database
409 * @param array<string, mixed> $file The uploaded file
410 * @param boolean $ajax If the image comes from an ajax call (dnd)
411 * @param ?array<string, mixed> $cropping Cropping properties
415 public function store(array $file, bool $ajax = false, array $cropping = null): bool|
int
417 /** TODO: fix max size (by preferences ?) */
420 $class = get_class($this);
422 $name = $file['name'];
423 $tmpfile = $file['tmp_name'];
425 //First, does the file have a valid name?
426 $reg = "/^([^" . implode('', $this->bad_chars
) . "]+)\.(" .
427 implode('|', $this->allowed_extensions
) . ")$/i";
428 if (preg_match($reg, $name, $matches)) {
430 '[' . $class . '] Filename and extension are OK, proceed.',
433 $extension = strtolower($matches[2]);
434 if ($extension == 'jpeg') {
435 //jpeg is an allowed extension,
436 //but we change it to jpg to reduce further tests :)
440 $erreg = "/^([^" . implode('', $this->bad_chars
) . "]+)\.(.*)/i";
441 $m = preg_match($erreg, $name, $errmatches);
443 $err_msg = '[' . $class . '] ';
445 //ok, we got a good filename and an extension. Extension is bad :)
446 $err_msg .= 'Invalid extension for file ' . $name . '.';
447 $ret = self
::INVALID_EXTENSION
;
449 $err_msg = 'Invalid filename `' . $name . '` (Tip: ';
450 $err_msg .= preg_replace(
452 htmlentities($this->getBadChars()),
453 "file name should not contain any of: %s). "
455 $ret = self
::INVALID_FILENAME
;
465 //Second, let's check file size
466 if ($file['size'] > ($this->maxlenght
* 1024)) {
468 '[' . $class . '] File is too big (' . ($file['size'] * 1024) .
469 'Ko for maximum authorized ' . ($this->maxlenght
* 1024) .
473 return self
::FILE_TOO_BIG
;
475 Analog
::log('[' . $class . '] Filesize is OK, proceed', Analog
::DEBUG
);
478 $current = getimagesize($tmpfile);
480 if (!in_array($current['mime'], $this->allowed_mimes
)) {
482 '[' . $class . '] Mimetype `' . $current['mime'] . '` not allowed',
485 return self
::MIME_NOT_ALLOWED
;
488 '[' . $class . '] Mimetype is allowed, proceed',
493 // Source image must have minimum dimensions to match the cropping process requirements
494 // and ensure the final picture will fit the maximum allowed resizing dimensions.
495 if (isset($cropping['ratio']) && isset($cropping['focus'])) {
496 if ($current[0] < $this->mincropsize ||
$current[1] < $this->mincropsize
) {
497 $min_current = min($current[0], $current[1]);
499 '[' . $class . '] Image is too small. The minimum image side size allowed is ' .
500 $this->mincropsize
. 'px, but current is ' . $min_current . 'px.',
503 return self
::IMAGE_TOO_SMALL
;
505 Analog
::log('[' . $class . '] Image dimensions are OK, proceed', Analog
::DEBUG
);
511 $new_file = $this->store_path
.
512 $this->id
. '.' . $extension;
513 if ($ajax === true) {
514 rename($tmpfile, $new_file);
516 move_uploaded_file($tmpfile, $new_file);
519 // current[0] gives width ; current[1] gives height
520 if ($current[0] > $this->max_width ||
$current[1] > $this->max_height
) {
521 /** FIXME: what if image cannot be resized?
522 Should'nt we want to stop the process here? */
523 $this->resizeImage($new_file, $extension, null, $cropping);
526 return $this->storeInDb($zdb, $this->db_id
, $new_file, $extension);
530 * Stores an image in the database
532 * @param Db $zdb Database instance
533 * @param int $id Member ID
534 * @param string $file File path on disk
535 * @param string $ext File extension
539 private function storeInDb(Db
$zdb, int $id, string $file, string $ext): bool|
int
541 $f = fopen($file, 'r');
543 while ($r = fread($f, 8192)) {
548 $class = get_class($this);
551 $zdb->connection
->beginTransaction();
553 if (isset($this->insert_stmt
)) {
554 $stmt = $this->insert_stmt
;
556 $insert = $zdb->insert($this->tbl_prefix
. $class::TABLE
);
559 $class::PK
=> ':' . $class::PK
,
560 'picture' => ':picture',
561 'format' => ':format'
564 $stmt = $zdb->sql
->prepareStatementForSqlObject($insert);
565 $container = $stmt->getParameterContainer();
566 $container->offsetSet(
567 'picture', //'picture',
571 $stmt->setParameterContainer($container);
572 $this->insert_stmt
= $stmt;
578 'picture' => $picture,
582 $zdb->connection
->commit();
583 $this->has_picture
= true;
584 } catch (Throwable
$e) {
585 $zdb->connection
->rollBack();
587 'An error occurred storing picture in database: ' .
591 return self
::SQL_ERROR
;
598 * Check for missing images in database
600 * @param Db $zdb Database instance
604 public function missingInDb(Db
$zdb): void
606 $existing_disk = array();
608 //retrieve files on disk
609 if ($handle = opendir($this->store_path
)) {
610 while (false !== ($entry = readdir($handle))) {
611 $reg = "/^(\d+)\.(" .
612 implode('|', $this->allowed_extensions
) . ")$/i";
613 if (preg_match($reg, $entry, $matches)) {
615 $extension = strtolower($matches[2]);
616 if ($extension == 'jpeg') {
617 //jpeg is an allowed extension,
618 //but we change it to jpg to reduce further tests :)
621 $existing_disk[$id] = array(
630 if (count($existing_disk) === 0) {
631 //no image on disk, nothing to do :)
635 //retrieve files in database
636 $class = get_class($this);
637 $select = $zdb->select($this->tbl_prefix
. $class::TABLE
);
639 ->columns(array($class::PK
))
640 ->where
->in($class::PK
, array_keys($existing_disk));
642 $results = $zdb->execute($select);
644 $existing_db = array();
645 foreach ($results as $result) {
646 $existing_db[] = (int)$result[self
::PK
];
649 $existing_diff = array_diff(array_keys($existing_disk), $existing_db);
651 //retrieve valid members ids
652 $members = new Members();
653 $valids = $members->getArrayList(
654 array_map('intval', $existing_diff),
661 foreach ($valids as $valid) {
662 /** @var ArrayObject<string,mixed> $valid */
663 $file = $existing_disk[$valid->id_adh
];
667 $this->store_path
. $file['id'] . '.' . $file['ext'],
673 'Something went wrong opening images directory ' .
681 * Resize and eventually crop the image if it exceeds max allowed sizes
683 * @param string $source The source image
684 * @param string $ext File's extension
685 * @param ?string $dest The destination image.
686 * If null, we'll use the source image. Defaults to null
687 * @param ?array<string, mixed> $cropping Cropping properties
691 private function resizeImage(string $source, string $ext, string $dest = null, array $cropping = null): bool
693 $class = get_class($this);
695 if (!function_exists("gd_info")) {
697 '[' . $class . '] GD is not present - ' .
698 'pictures could not be resized!',
705 $h = $this->max_height
;
706 $w = $this->max_width
;
711 switch (strtolower($ext)) {
713 if (!$gdinfo['JPEG Support']) {
715 '[' . $class . '] GD has no JPEG Support - ' .
716 'pictures could not be resized!',
723 if (!$gdinfo['PNG Support']) {
725 '[' . $class . '] GD has no PNG Support - ' .
726 'pictures could not be resized!',
733 if (!$gdinfo['GIF Create Support']) {
735 '[' . $class . '] GD has no GIF Support - ' .
736 'pictures could not be resized!',
743 if (!$gdinfo['WebP Support']) {
745 '[' . $class . '] GD has no WebP Support - ' .
746 'pictures could not be resized!',
757 list($cur_width, $cur_height, $cur_type, $curattr)
758 = getimagesize($source);
760 $ratio = $cur_width / $cur_height;
762 // Define cropping variables if necessary.
763 $thumb_cropped = false;
764 // Cropping is based on the smallest side of the source in order to
765 // provide as less focusing options as possible if the source doesn't
766 // fit the final ratio (center, top, bottom, left, right).
767 $min_size = min($cur_width, $cur_height);
768 // Cropping dimensions.
769 $crop_width = $min_size;
770 $crop_height = $min_size;
774 if (isset($cropping['ratio']) && isset($cropping['focus'])) {
775 // Calculate cropping dimensions
776 switch ($cropping['ratio']) {
777 case 'portrait_ratio':
778 // Calculate cropping dimensions
780 $crop_height = ceil($crop_width * 4 / 3);
782 $crop_width = ceil($crop_height * 3 / 4);
784 // Calculate resizing dimensions
785 $w = ceil($h * 3 / 4);
787 case 'landscape_ratio':
788 // Calculate cropping dimensions
790 $crop_width = ceil($crop_height * 4 / 3);
792 $crop_height = ceil($crop_width * 3 / 4);
794 // Calculate resizing dimensions
795 $h = ceil($w * 3 / 4);
798 // Calculate focus coordinates
799 switch ($cropping['focus']) {
802 $crop_x = ceil(($cur_width - $crop_width) / 2);
803 } elseif ($ratio == 1) {
804 $crop_x = ceil(($cur_width - $crop_width) / 2);
805 $crop_y = ceil(($cur_height - $crop_height) / 2);
807 $crop_y = ceil(($cur_height - $crop_height) / 2);
811 $crop_x = ceil(($cur_width - $crop_width) / 2);
814 $crop_y = $cur_height - $crop_height;
817 $crop_x = $cur_width - $crop_width;
821 $thumb_cropped = imagecreatetruecolor($crop_width, $crop_height);
823 $ratio = $crop_width / $crop_height;
824 // Otherwise, calculate image size according to the source's ratio.
826 if ($cur_width > $cur_height) {
827 $h = round($w / $ratio);
829 $w = round($h * $ratio);
834 $thumb = imagecreatetruecolor($w, $h);
839 $image = imagecreatefromjpeg($source);
841 if ($thumb_cropped !== false) {
843 imagecopyresampled($thumb_cropped, $image, 0, 0, $crop_x, $crop_y, $cur_width, $cur_height, $cur_width, $cur_height);
845 imagecopyresampled($thumb, $thumb_cropped, 0, 0, 0, 0, $w, $h, $crop_width, $crop_height);
848 imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height);
850 imagejpeg($thumb, $dest);
853 $image = imagecreatefrompng($source);
854 // Turn off alpha blending and set alpha flag. That prevent alpha
855 // transparency to be saved as an arbitrary color (black in my tests)
856 imagealphablending($image, false);
857 imagesavealpha($image, true);
858 imagealphablending($thumb, false);
859 imagesavealpha($thumb, true);
861 if ($thumb_cropped !== false) {
862 imagealphablending($thumb_cropped, false);
863 imagesavealpha($thumb_cropped, true);
865 imagecopyresampled($thumb_cropped, $image, 0, 0, $crop_x, $crop_y, $cur_width, $cur_height, $cur_width, $cur_height);
867 imagecopyresampled($thumb, $thumb_cropped, 0, 0, 0, 0, $w, $h, $crop_width, $crop_height);
870 imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height);
872 imagepng($thumb, $dest);
875 $image = imagecreatefromgif($source);
877 if ($thumb_cropped !== false) {
879 imagecopyresampled($thumb_cropped, $image, 0, 0, $crop_x, $crop_y, $cur_width, $cur_height, $cur_width, $cur_height);
881 imagecopyresampled($thumb, $thumb_cropped, 0, 0, 0, 0, $w, $h, $crop_width, $crop_height);
884 imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height);
886 imagegif($thumb, $dest);
889 $image = imagecreatefromwebp($source);
891 if ($thumb_cropped !== false) {
893 imagecopyresampled($thumb_cropped, $image, 0, 0, $crop_x, $crop_y, $cur_width, $cur_height, $cur_width, $cur_height);
895 imagecopyresampled($thumb, $thumb_cropped, 0, 0, 0, 0, $w, $h, $crop_width, $crop_height);
898 imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height);
900 imagewebp($thumb, $dest);
908 * Returns current file optimal height (resized)
910 * @return int optimal height
912 public function getOptimalHeight(): int
914 return (int)round($this->optimal_height
, 1);
918 * Returns current file height
920 * @return int current height
922 public function getHeight(): int
924 return $this->height
;
928 * Returns current file optimal width (resized)
930 * @return int optimal width
932 public function getOptimalWidth(): int
934 return (int)round($this->optimal_width
, 1);
938 * Returns current file width
940 * @return int current width
942 public function getWidth(): int
948 * Returns current file format
952 public function getFormat(): string
954 return $this->format
;
958 * Have we got a picture ?
960 * @return bool True if a picture matches adherent's id, false otherwise
962 public function hasPicture(): bool
964 return $this->has_picture
;
968 * Returns current file full path
970 * @return string full file path
972 public function getPath(): string
974 return $this->file_path
;
978 * Returns current mime type
982 public function getMime(): string
988 * Return textual error message
990 * @param int $code The error code
992 * @return string Localized message
994 public function getErrorMessage($code): string
998 case self
::SQL_ERROR
:
999 case self
::SQL_BLOB_ERROR
:
1000 $error = _T("An SQL error has occurred.");
1004 if ($error === null) {
1005 $error = $this->getErrorMessageFromCode($code);