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