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