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
44 writeOnDisk
as protected trait_writeOnDisk
;
45 store
as protected trait_store
;
46 getMimeType
as protected trait_getMimeType
;
49 //constants that will not be overridden
50 public const SQL_ERROR
= -10;
51 public const SQL_BLOB_ERROR
= -11;
52 //constants that can be overridden
53 //(do not use self::CONSTANT, but get_class[$this]::CONSTANT)
54 public const TABLE
= 'pictures';
55 public const PK
= Adherent
::PK
;
57 protected string $tbl_prefix = '';
59 protected string|
int $id;
61 protected int $height;
63 protected int $optimal_height;
64 protected int $optimal_width;
65 protected string $file_path;
66 protected string $format;
67 protected string $mime;
68 protected bool $has_picture = false;
69 protected string $store_path = GALETTE_PHOTOS_PATH
;
70 protected int $max_width = 200;
71 protected int $max_height = 200;
72 private StatementInterface
$insert_stmt;
73 /** @var ?array<string, mixed> */
74 private ?
array $cropping;
77 * Default constructor.
79 * @param mixed|null $id_adh the id of the member
81 public function __construct($id_adh = null)
85 array('jpeg', 'jpg', 'png', 'gif', 'webp'),
87 'jpg' => 'image/jpeg',
90 'webp' => 'image/webp'
94 // '!==' needed, otherwise ''==0
95 if ($id_adh !== '' && $id_adh !== null) {
97 if (!isset($this->db_id
)) {
98 $this->db_id
= $id_adh;
101 //if file does not exist on the FileSystem, check for it in the database
102 if (!$this->checkFileOnFS()) {
103 if ($this->checkFileInDB()) {
104 $this->has_picture
= true;
107 $this->has_picture
= true;
111 // if we still have no picture, take the default one
112 if (empty($this->file_path
)) {
113 $this->getDefaultPicture();
116 //we should not have an empty file_path, but...
117 if (!empty($this->file_path
)) {
123 * "Magic" function called on unserialize
127 public function __wakeup(): void
129 //if file has been deleted since we store our object in the session,
130 //we try to retrieve it
131 if (isset($this->id
) && !$this->checkFileOnFS()) {
132 //if file does not exist on the FileSystem,
133 //check for it in the database
134 //$this->checkFileInDB();
136 $this->has_picture
= false;
139 // if we still have no picture, take the default one
140 if (empty($this->file_path
)) {
141 $this->getDefaultPicture();
144 //we should not have an empty file_path, but...
145 if (!empty($this->file_path
)) {
151 * Check if current file is present on the File System
153 * @return boolean true if file is present on FS, false otherwise
155 private function checkFileOnFS(): bool
157 $file_wo_ext = $this->store_path
. $this->id
;
158 if (file_exists($file_wo_ext . '.jpg')) {
159 $this->file_path
= realpath($file_wo_ext . '.jpg');
160 $this->format
= 'jpg';
161 $this->mime
= 'image/jpeg';
163 } elseif (file_exists($file_wo_ext . '.png')) {
164 $this->file_path
= realpath($file_wo_ext . '.png');
165 $this->format
= 'png';
166 $this->mime
= 'image/png';
168 } elseif (file_exists($file_wo_ext . '.gif')) {
169 $this->file_path
= realpath($file_wo_ext . '.gif');
170 $this->format
= 'gif';
171 $this->mime
= 'image/gif';
173 } elseif (file_exists($file_wo_ext . '.webp')) {
174 $this->file_path
= realpath($file_wo_ext . '.webp');
175 $this->format
= 'webp';
176 $this->mime
= 'image/webp';
183 * Check if current file is present in the database,
184 * and copy it to the File System
186 * @return boolean true if file is present in the DB, false otherwise
188 private function checkFileInDB(): bool
193 $select = $this->getCheckFileQuery();
194 $results = $zdb->execute($select);
195 $pic = $results->current();
198 // we must regenerate the picture file
199 $file_wo_ext = $this->store_path
. $this->id
;
201 $file_wo_ext . '.' . $pic->format
,
205 $this->format
= $pic->format
;
206 switch ($this->format
) {
208 $this->mime
= 'image/jpeg';
211 $this->mime
= 'image/png';
214 $this->mime
= 'image/gif';
217 $this->mime
= 'image/webp';
220 $this->file_path
= realpath($file_wo_ext . '.' . $this->format
);
223 } catch (Throwable
$e) {
230 * Returns the relevant query to check if picture exists in database.
232 * @return Select SELECT query
234 protected function getCheckFileQuery(): Select
237 $class = get_class($this);
239 $select = $zdb->select($this->tbl_prefix
. $class::TABLE
);
246 $select->where(array($class::PK
=> $this->db_id
));
251 * Gets the default picture to show, anyway
255 protected function getDefaultPicture(): void
257 $this->file_path
= realpath(_CURRENT_THEME_PATH
. 'images/default.png');
258 $this->format
= 'png';
259 $this->mime
= 'image/png';
260 $this->has_picture
= false;
268 private function setSizes(): void
270 list($width, $height) = getimagesize($this->file_path
);
271 $this->height
= $height;
272 $this->width
= $width;
273 $this->optimal_height
= $height;
274 $this->optimal_width
= $width;
276 if ($this->height
> $this->width
) {
277 if ($this->height
> $this->max_height
) {
278 $ratio = $this->max_height
/ $this->height
;
279 $this->optimal_height
= $this->max_height
;
280 $this->optimal_width
= (int)($this->width
* $ratio);
283 if ($this->width
> $this->max_width
) {
284 $ratio = $this->max_width
/ $this->width
;
285 $this->optimal_width
= $this->max_width
;
286 $this->optimal_height
= (int)($this->height
* $ratio);
292 * Get image file content
296 public function getContents()
298 readfile($this->file_path
);
302 * Set header and displays the picture.
304 * @param Response $response Response
306 * @return Response the binary file
308 public function display(Response
$response): Response
310 $response = $response->withHeader('Content-Type', $this->mime
)
311 ->withHeader('Content-Transfer-Encoding', 'binary')
312 ->withHeader('Expires', '0')
313 ->withHeader('Cache-Control', 'must-revalidate')
314 ->withHeader('Pragma', 'public');
316 $stream = fopen('php://memory', 'r+');
317 fwrite($stream, file_get_contents($this->file_path
));
320 return $response->withBody(new \Slim\Psr7\
Stream($stream));
324 * Deletes a picture, from both database and filesystem
326 * @param boolean $transaction Whether to use a transaction here or not
328 * @return boolean true if image was successfully deleted, false otherwise
330 public function delete(bool $transaction = true): bool
333 $class = get_class($this);
336 if ($transaction === true) {
337 $zdb->connection
->beginTransaction();
340 $delete = $zdb->delete($this->tbl_prefix
. $class::TABLE
);
341 $delete->where([$class::PK
=> $this->db_id
]);
342 $del = $zdb->execute($delete);
344 if (!$del->count() > 0) {
346 'Unable to remove picture database entry for ' . $this->db_id
,
349 //it may be possible image is missing in the database.
350 //let's try to remove file anyway.
353 $file_wo_ext = $this->store_path
. $this->id
;
355 // take back default picture
356 $this->getDefaultPicture();
362 if (file_exists($file_wo_ext . '.jpg')) {
363 //return unlink($file_wo_ext . '.jpg');
364 $_file = $file_wo_ext . '.jpg';
365 $success = unlink($_file);
366 } elseif (file_exists($file_wo_ext . '.png')) {
367 //return unlink($file_wo_ext . '.png');
368 $_file = $file_wo_ext . '.png';
369 $success = unlink($_file);
370 } elseif (file_exists($file_wo_ext . '.gif')) {
371 //return unlink($file_wo_ext . '.gif');
372 $_file = $file_wo_ext . '.gif';
373 $success = unlink($_file);
374 } elseif (file_exists($file_wo_ext . '.webp')) {
375 //return unlink($file_wo_ext . '.webp');
376 $_file = $file_wo_ext . '.webp';
377 $success = unlink($_file);
380 if ($_file !== null && $success !== true) {
381 //unable to remove file that exists!
382 if ($transaction === true) {
383 $zdb->connection
->rollBack();
386 'The file ' . $_file .
387 ' was found on the disk but cannot be removed.',
392 if ($transaction === true) {
393 $zdb->connection
->commit();
395 $this->has_picture
= false;
398 } catch (Throwable
$e) {
399 if ($transaction === true) {
400 $zdb->connection
->rollBack();
403 'An error occurred attempting to delete picture ' . $this->db_id
.
404 'from database | ' . $e->getMessage(),
412 * Stores an image on the disk and in the database
414 * @param array<string, mixed> $file The uploaded file
415 * @param boolean $ajax If the image comes from an ajax call (dnd)
416 * @param ?array<string, mixed> $cropping Cropping properties
420 public function store(array $file, bool $ajax = false, array $cropping = null): bool|
int
422 $this->cropping
= $cropping;
423 return $this->trait_store($file, $ajax);
427 * Build destination path
431 protected function buildDestPath(): string
433 return $this->dest_dir
. $this->id
. '.' . $this->extension
;
439 * @param string $file File
443 public static function getMimeType(string $file): string
445 $info = getimagesize($file);
446 if ($info !== false) {
447 return $info['mime'];
450 //fallback if file is not an image
451 return static::trait_getMimeType($file);
457 * @param string $tmpfile Temporary file
458 * @param bool $ajax If the file comes from an ajax call (dnd)
462 public function writeOnDisk(string $tmpfile, bool $ajax): bool|
int
466 $this->setDestDir($this->store_path
);
467 $current = getimagesize($tmpfile);
469 // Source image must have minimum dimensions to match the cropping process requirements
470 // and ensure the final picture will fit the maximum allowed resizing dimensions.
471 if (isset($this->cropping
['ratio']) && isset($this->cropping
['focus'])) {
472 if ($current[0] < $this->mincropsize ||
$current[1] < $this->mincropsize
) {
473 $min_current = min($current[0], $current[1]);
475 '[' . get_class($this) . '] Image is too small. The minimum image side size allowed is ' .
476 $this->mincropsize
. 'px, but current is ' . $min_current . 'px.',
479 return self
::IMAGE_TOO_SMALL
;
481 Analog
::log('[' . get_class($this) . '] Image dimensions are OK, proceed', Analog
::DEBUG
);
486 $result = $this->trait_writeOnDisk($tmpfile, $ajax);
487 if ($result !== true) {
491 // current[0] gives width ; current[1] gives height
492 if ($current[0] > $this->max_width ||
$current[1] > $this->max_height
) {
493 /** FIXME: what if image cannot be resized?
494 Should'nt we want to stop the process here? */
495 $this->resizeImage($this->buildDestPath(), $this->extension
, null, $this->cropping
);
498 return $this->storeInDb($zdb, $this->db_id
, $this->buildDestPath(), $this->extension
);
502 * Stores an image in the database
504 * @param Db $zdb Database instance
505 * @param int $id Member ID
506 * @param string $file File path on disk
507 * @param string $ext File extension
511 private function storeInDb(Db
$zdb, int $id, string $file, string $ext): bool|
int
513 $f = fopen($file, 'r');
515 while ($r = fread($f, 8192)) {
520 $class = get_class($this);
523 $zdb->connection
->beginTransaction();
525 if (isset($this->insert_stmt
)) {
526 $stmt = $this->insert_stmt
;
528 $insert = $zdb->insert($this->tbl_prefix
. $class::TABLE
);
531 $class::PK
=> ':' . $class::PK
,
532 'picture' => ':picture',
533 'format' => ':format'
536 $stmt = $zdb->sql
->prepareStatementForSqlObject($insert);
537 $container = $stmt->getParameterContainer();
538 $container->offsetSet(
539 'picture', //'picture',
543 $stmt->setParameterContainer($container);
544 $this->insert_stmt
= $stmt;
550 'picture' => $picture,
554 $zdb->connection
->commit();
555 $this->has_picture
= true;
556 } catch (Throwable
$e) {
557 $zdb->connection
->rollBack();
559 'An error occurred storing picture in database: ' .
563 return self
::SQL_ERROR
;
570 * Check for missing images in database
572 * @param Db $zdb Database instance
576 public function missingInDb(Db
$zdb): void
578 $existing_disk = array();
580 //retrieve files on disk
581 if ($handle = opendir($this->store_path
)) {
582 while (false !== ($entry = readdir($handle))) {
583 $reg = "/^(\d+)\.(" .
584 implode('|', $this->allowed_extensions
) . ")$/i";
585 if (preg_match($reg, $entry, $matches)) {
587 $extension = strtolower($matches[2]);
588 if ($extension == 'jpeg') {
589 //jpeg is an allowed extension,
590 //but we change it to jpg to reduce further tests :)
593 $existing_disk[$id] = array(
602 if (count($existing_disk) === 0) {
603 //no image on disk, nothing to do :)
607 //retrieve files in database
608 $class = get_class($this);
609 $select = $zdb->select($this->tbl_prefix
. $class::TABLE
);
611 ->columns(array($class::PK
))
612 ->where
->in($class::PK
, array_keys($existing_disk));
614 $results = $zdb->execute($select);
616 $existing_db = array();
617 foreach ($results as $result) {
618 $existing_db[] = (int)$result[self
::PK
];
621 $existing_diff = array_diff(array_keys($existing_disk), $existing_db);
623 //retrieve valid members ids
624 $members = new Members();
625 $valids = $members->getArrayList(
626 array_map('intval', $existing_diff),
633 foreach ($valids as $valid) {
634 /** @var ArrayObject<string,mixed> $valid */
635 $file = $existing_disk[$valid->id_adh
];
639 $this->store_path
. $file['id'] . '.' . $file['ext'],
645 'Something went wrong opening images directory ' .
653 * Resize and eventually crop the image if it exceeds max allowed sizes
655 * @param string $source The source image
656 * @param string $ext File's extension
657 * @param ?string $dest The destination image.
658 * If null, we'll use the source image. Defaults to null
659 * @param ?array<string, mixed> $cropping Cropping properties
663 private function resizeImage(string $source, string $ext, string $dest = null, array $cropping = null): bool
665 $class = get_class($this);
667 if (!function_exists("gd_info")) {
669 '[' . $class . '] GD is not present - ' .
670 'pictures could not be resized!',
677 $h = $this->max_height
;
678 $w = $this->max_width
;
683 switch (strtolower($ext)) {
685 if (!$gdinfo['JPEG Support']) {
687 '[' . $class . '] GD has no JPEG Support - ' .
688 'pictures could not be resized!',
695 if (!$gdinfo['PNG Support']) {
697 '[' . $class . '] GD has no PNG Support - ' .
698 'pictures could not be resized!',
705 if (!$gdinfo['GIF Create Support']) {
707 '[' . $class . '] GD has no GIF Support - ' .
708 'pictures could not be resized!',
715 if (!$gdinfo['WebP Support']) {
717 '[' . $class . '] GD has no WebP Support - ' .
718 'pictures could not be resized!',
729 list($cur_width, $cur_height, $cur_type, $curattr)
730 = getimagesize($source);
732 $ratio = $cur_width / $cur_height;
734 // Define cropping variables if necessary.
735 $thumb_cropped = false;
736 // Cropping is based on the smallest side of the source in order to
737 // provide as less focusing options as possible if the source doesn't
738 // fit the final ratio (center, top, bottom, left, right).
739 $min_size = min($cur_width, $cur_height);
740 // Cropping dimensions.
741 $crop_width = $min_size;
742 $crop_height = $min_size;
746 if (isset($cropping['ratio']) && isset($cropping['focus'])) {
747 // Calculate cropping dimensions
748 switch ($cropping['ratio']) {
749 case 'portrait_ratio':
750 // Calculate cropping dimensions
752 $crop_height = ceil($crop_width * 4 / 3);
754 $crop_width = ceil($crop_height * 3 / 4);
756 // Calculate resizing dimensions
757 $w = ceil($h * 3 / 4);
759 case 'landscape_ratio':
760 // Calculate cropping dimensions
762 $crop_width = ceil($crop_height * 4 / 3);
764 $crop_height = ceil($crop_width * 3 / 4);
766 // Calculate resizing dimensions
767 $h = ceil($w * 3 / 4);
770 // Calculate focus coordinates
771 switch ($cropping['focus']) {
774 $crop_x = ceil(($cur_width - $crop_width) / 2);
775 } elseif ($ratio == 1) {
776 $crop_x = ceil(($cur_width - $crop_width) / 2);
777 $crop_y = ceil(($cur_height - $crop_height) / 2);
779 $crop_y = ceil(($cur_height - $crop_height) / 2);
783 $crop_x = ceil(($cur_width - $crop_width) / 2);
786 $crop_y = $cur_height - $crop_height;
789 $crop_x = $cur_width - $crop_width;
793 $thumb_cropped = imagecreatetruecolor($crop_width, $crop_height);
795 $ratio = $crop_width / $crop_height;
796 // Otherwise, calculate image size according to the source's ratio.
798 if ($cur_width > $cur_height) {
799 $h = round($w / $ratio);
801 $w = round($h * $ratio);
806 $thumb = imagecreatetruecolor($w, $h);
811 $image = imagecreatefromjpeg($source);
813 if ($thumb_cropped !== false) {
815 imagecopyresampled($thumb_cropped, $image, 0, 0, $crop_x, $crop_y, $cur_width, $cur_height, $cur_width, $cur_height);
817 imagecopyresampled($thumb, $thumb_cropped, 0, 0, 0, 0, $w, $h, $crop_width, $crop_height);
820 imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height);
822 imagejpeg($thumb, $dest);
825 $image = imagecreatefrompng($source);
826 // Turn off alpha blending and set alpha flag. That prevent alpha
827 // transparency to be saved as an arbitrary color (black in my tests)
828 imagealphablending($image, false);
829 imagesavealpha($image, true);
830 imagealphablending($thumb, false);
831 imagesavealpha($thumb, true);
833 if ($thumb_cropped !== false) {
834 imagealphablending($thumb_cropped, false);
835 imagesavealpha($thumb_cropped, true);
837 imagecopyresampled($thumb_cropped, $image, 0, 0, $crop_x, $crop_y, $cur_width, $cur_height, $cur_width, $cur_height);
839 imagecopyresampled($thumb, $thumb_cropped, 0, 0, 0, 0, $w, $h, $crop_width, $crop_height);
842 imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height);
844 imagepng($thumb, $dest);
847 $image = imagecreatefromgif($source);
849 if ($thumb_cropped !== false) {
851 imagecopyresampled($thumb_cropped, $image, 0, 0, $crop_x, $crop_y, $cur_width, $cur_height, $cur_width, $cur_height);
853 imagecopyresampled($thumb, $thumb_cropped, 0, 0, 0, 0, $w, $h, $crop_width, $crop_height);
856 imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height);
858 imagegif($thumb, $dest);
861 $image = imagecreatefromwebp($source);
863 if ($thumb_cropped !== false) {
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 imagewebp($thumb, $dest);
880 * Returns current file optimal height (resized)
882 * @return int optimal height
884 public function getOptimalHeight(): int
886 return (int)round($this->optimal_height
, 1);
890 * Returns current file height
892 * @return int current height
894 public function getHeight(): int
896 return $this->height
;
900 * Returns current file optimal width (resized)
902 * @return int optimal width
904 public function getOptimalWidth(): int
906 return (int)round($this->optimal_width
, 1);
910 * Returns current file width
912 * @return int current width
914 public function getWidth(): int
920 * Returns current file format
924 public function getFormat(): string
926 return $this->format
;
930 * Have we got a picture ?
932 * @return bool True if a picture matches adherent's id, false otherwise
934 public function hasPicture(): bool
936 return $this->has_picture
;
940 * Returns current file full path
942 * @return string full file path
944 public function getPath(): string
946 return $this->file_path
;
950 * Returns current mime type
954 public function getMime(): string
960 * Return textual error message
962 * @param int $code The error code
964 * @return string Localized message
966 public function getErrorMessage($code): string
970 case self
::SQL_ERROR
:
971 case self
::SQL_BLOB_ERROR
:
972 $error = _T("An SQL error has occurred.");
976 if ($error === null) {
977 $error = $this->getErrorMessageFromCode($code);