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