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