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'),
101 'jpg' => 'image/jpeg',
102 'png' => 'image/png',
107 // '!==' needed, otherwise ''==0
108 if ($id_adh !== '' && $id_adh !== null) {
110 if (!isset($this->db_id
)) {
111 $this->db_id
= $id_adh;
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;
120 $this->has_picture
= true;
124 // if we still have no picture, take the default one
125 if (empty($this->file_path
)) {
126 $this->getDefaultPicture();
129 //we should not have an empty file_path, but...
130 if (!empty($this->file_path
)) {
136 * "Magic" function called on unserialize
140 public function __wakeup()
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();
149 $this->has_picture
= false;
152 // if we still have no picture, take the default one
153 if (empty($this->file_path
)) {
154 $this->getDefaultPicture();
157 //we should not have an empty file_path, but...
158 if (!empty($this->file_path
)) {
164 * Check if current file is present on the File System
166 * @return boolean true if file is present on FS, false otherwise
168 private function checkFileOnFS()
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';
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';
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';
191 * Check if current file is present in the database,
192 * and copy it to the File System
194 * @return boolean true if file is present in the DB, false otherwise
196 private function checkFileInDB()
201 $select = $this->getCheckFileQuery();
202 $results = $zdb->execute($select);
203 $pic = $results->current();
206 // we must regenerate the picture file
207 $file_wo_ext = $this->store_path
. $this->id
;
209 $file_wo_ext . '.' . $pic->format
,
213 $this->format
= $pic->format
;
214 switch ($this->format
) {
216 $this->mime
= 'image/jpeg';
219 $this->mime
= 'image/png';
222 $this->mime
= 'image/gif';
225 $this->file_path
= realpath($file_wo_ext . '.' . $this->format
);
228 } catch (Throwable
$e) {
235 * Returns the relevant query to check if picture exists in database.
237 * @return string SELECT query
239 protected function getCheckFileQuery()
242 $class = get_class($this);
244 $select = $zdb->select($this->tbl_prefix
. $class::TABLE
);
251 $select->where(array($class::PK
=> $this->db_id
));
256 * Gets the default picture to show, anyways
260 protected function getDefaultPicture()
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;
273 private function setSizes()
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;
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;
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;
297 * Get image file contents
301 public function getContents()
303 readfile($this->file_path
);
307 * Set header and displays the picture.
309 * @param Response $response Response
311 * @return object the binary file
313 public function display(Response
$response)
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');
321 $stream = fopen('php://memory', 'r+');
322 fwrite($stream, file_get_contents($this->file_path
));
325 return $response->withBody(new \Slim\Psr7\
Stream($stream));
329 * Deletes a picture, from both database and filesystem
331 * @param boolean $transaction Whether to use a transaction here or not
333 * @return boolean true if image was successfully deleted, false otherwise
335 public function delete($transaction = true)
338 $class = get_class($this);
341 if ($transaction === true) {
342 $zdb->connection
->beginTransaction();
345 $delete = $zdb->delete($this->tbl_prefix
. $class::TABLE
);
346 $delete->where([$class::PK
=> $this->db_id
]);
347 $del = $zdb->execute($delete);
349 if (!$del->count() > 0) {
351 'Unable to remove picture database entry for ' . $this->db_id
,
354 //it may be possible image is missing in the database.
355 //let's try to remove file anyway.
358 $file_wo_ext = $this->store_path
. $this->id
;
360 // take back default picture
361 $this->getDefaultPicture();
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);
381 if ($_file !== null && $success !== true) {
382 //unable to remove file that exists!
383 if ($transaction === true) {
384 $zdb->connection
->rollBack();
387 'The file ' . $_file .
388 ' was found on the disk but cannot be removed.',
393 if ($transaction === true) {
394 $zdb->connection
->commit();
396 $this->has_picture
= false;
399 } catch (Throwable
$e) {
400 if ($transaction === true) {
401 $zdb->connection
->rollBack();
404 'An error occurred attempting to delete picture ' . $this->db_id
.
405 'from database | ' . $e->getMessage(),
413 * Stores an image on the disk and in the database
415 * @param object $file the uploaded file
416 * @param boolean $ajax If the image cames from an ajax call (dnd)
420 public function store($file, $ajax = false)
422 /** TODO: fix max size (by preferences ?) */
425 $class = get_class($this);
427 $name = $file['name'];
428 $tmpfile = $file['tmp_name'];
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)) {
435 '[' . $class . '] Filename and extension are OK, proceed.',
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 :)
445 $erreg = "/^([^" . implode('', $this->bad_chars
) . "]+)\.(.*)/i";
446 $m = preg_match($erreg, $name, $errmatches);
448 $err_msg = '[' . $class . '] ';
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
;
454 $err_msg = 'Invalid filename `' . $name . '` (Tip: ';
455 $err_msg .= preg_replace(
457 htmlentities($this->getBadChars()),
458 "file name should not contain any of: %s). "
460 $ret = self
::INVALID_FILENAME
;
470 //Second, let's check file size
471 if ($file['size'] > ($this->maxlenght
* 1024)) {
473 '[' . $class . '] File is too big (' . ($file['size'] * 1024) .
474 'Ko for maximum authorized ' . ($this->maxlenght
* 1024) .
478 return self
::FILE_TOO_BIG
;
480 Analog
::log('[' . $class . '] Filesize is OK, proceed', Analog
::DEBUG
);
483 $current = getimagesize($tmpfile);
485 if (!in_array($current['mime'], $this->allowed_mimes
)) {
487 '[' . $class . '] Mimetype `' . $current['mime'] . '` not allowed',
490 return self
::MIME_NOT_ALLOWED
;
493 '[' . $class . '] Mimetype is allowed, proceed',
500 $new_file = $this->store_path
.
501 $this->id
. '.' . $extension;
502 if ($ajax === true) {
503 rename($tmpfile, $new_file);
505 move_uploaded_file($tmpfile, $new_file);
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);
515 return $this->storeInDb($zdb, $this->db_id
, $new_file, $extension);
519 * Stores an image in the database
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
528 private function storeInDb(Db
$zdb, $id, $file, $ext)
530 $f = fopen($file, 'r');
532 while ($r = fread($f, 8192)) {
537 $class = get_class($this);
540 $zdb->connection
->beginTransaction();
541 $stmt = $this->insert_stmt
;
543 $insert = $zdb->insert($this->tbl_prefix
. $class::TABLE
);
546 $class::PK
=> ':' . $class::PK
,
547 'picture' => ':picture',
548 'format' => ':format'
551 $stmt = $zdb->sql
->prepareStatementForSqlObject($insert);
552 $container = $stmt->getParameterContainer();
553 $container->offsetSet(
554 'picture', //'picture',
558 $stmt->setParameterContainer($container);
559 $this->insert_stmt
= $stmt;
565 'picture' => $picture,
569 $zdb->connection
->commit();
570 $this->has_picture
= true;
571 } catch (Throwable
$e) {
572 $zdb->connection
->rollBack();
574 'An error occurred storing picture in database: ' .
578 return self
::SQL_ERROR
;
585 * Check for missing images in database
587 * @param Db $zdb Database instance
591 public function missingInDb(Db
$zdb)
593 $existing_disk = array();
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)) {
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 :)
608 $existing_disk[$id] = array(
617 if (count($existing_disk) === 0) {
618 //no image on disk, nothing to do :)
622 //retrieve files in database
623 $class = get_class($this);
624 $select = $zdb->select($this->tbl_prefix
. $class::TABLE
);
626 ->columns(array($class::PK
))
627 ->where
->in($class::PK
, array_keys($existing_disk));
629 $results = $zdb->execute($select);
631 $existing_db = array();
632 foreach ($results as $result) {
633 $existing_db[] = (int)$result[self
::PK
];
636 $existing_diff = array_diff(array_keys($existing_disk), $existing_db);
638 //retrieve valid members ids
639 $members = new Members();
640 $valids = $members->getArrayList(
648 foreach ($valids as $valid) {
649 /** @var ArrayObject $valid */
650 $file = $existing_disk[$valid->id_adh
];
654 $this->store_path
. $file['id'] . '.' . $file['ext'],
660 'Something went wrong opening images directory ' .
668 * Resize the image if it exceeds max allowed sizes
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
677 private function resizeImage($source, $ext, $dest = null)
679 $class = get_class($this);
681 if (function_exists("gd_info")) {
683 $h = $this->max_height
;
684 $w = $this->max_width
;
689 switch (strtolower($ext)) {
691 if (!$gdinfo['JPEG Support']) {
693 '[' . $class . '] GD has no JPEG Support - ' .
694 'pictures could not be resized!',
701 if (!$gdinfo['PNG Support']) {
703 '[' . $class . '] GD has no PNG Support - ' .
704 'pictures could not be resized!',
711 if (!$gdinfo['GIF Create Support']) {
713 '[' . $class . '] GD has no GIF Support - ' .
714 'pictures could not be resized!',
724 list($cur_width, $cur_height, $cur_type, $curattr)
725 = getimagesize($source);
727 $ratio = $cur_width / $cur_height;
729 // calculate image size according to ratio
730 if ($cur_width > $cur_height) {
731 $h = round($w / $ratio);
733 $w = round($h * $ratio);
736 $thumb = imagecreatetruecolor($w, $h);
739 $image = imagecreatefromjpeg($source);
740 imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height);
741 imagejpeg($thumb, $dest);
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);
755 $image = imagecreatefromgif($source);
756 imagecopyresampled($thumb, $image, 0, 0, 0, 0, $w, $h, $cur_width, $cur_height);
757 imagegif($thumb, $dest);
762 '[' . $class . '] GD is not present - ' .
763 'pictures could not be resized!',
770 * Returns current file optimal height (resized)
772 * @return int optimal height
774 public function getOptimalHeight()
776 return (int)round($this->optimal_height
, 1);
780 * Returns current file height
782 * @return int current height
784 public function getHeight()
786 return $this->height
;
790 * Returns current file optimal width (resized)
792 * @return int optimal width
794 public function getOptimalWidth()
796 return (int)round($this->optimal_width
, 1);
800 * Returns current file width
802 * @return int current width
804 public function getWidth()
810 * Returns current file format
814 public function getFormat()
816 return $this->format
;
820 * Have we got a picture ?
822 * @return bool True if a picture matches adherent's id, false otherwise
824 public function hasPicture()
826 return $this->has_picture
;
830 * Returns current file full path
832 * @return string full file path
834 public function getPath()
836 return $this->file_path
;
840 * Returns current mime type
844 public function getMime()
850 * Return textual error message
852 * @param int $code The error code
854 * @return string Localized message
856 public function getErrorMessage($code)
860 case self
::SQL_ERROR
:
861 case self
::SQL_BLOB_ERROR
:
862 $error = _T("An SQL error has occurred.");
866 if ($error === null) {
867 $error = $this->getErrorMessageFromCode($code);