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