<?php
/**
* @brief Return a photo_driver object.
*
* Use this factory when manipulating images.
*
* Return a photo driver object implementing ImageMagick or GD.
*
* @param string $data Image data
* @param string $type
* @return null|photo_driver
* NULL if unsupported image type or failure, otherwise photo driver object
*/
function photo_factory($data, $type = null) {
$ph = null;
$m = null;
$unsupported_types = [
'image/bmp',
'image/vnd.microsoft.icon',
'image/tiff',
'image/svg+xml',
];
if($type && in_array(strtolower($type), $unsupported_types)) {
logger('Unsupported image type ' . $type);
return null;
}
$ignore_imagick = get_config('system', 'ignore_imagick');
if(class_exists('Imagick') && !$ignore_imagick) {
$v = Imagick::getVersion();
preg_match('/ImageMagick ([0-9]+\.[0-9]+\.[0-9]+)/', $v['versionString'], $m);
if(version_compare($m[1], '6.6.7') >= 0) {
require_once('include/photo/photo_imagick.php');
$ph = new photo_imagick($data, $type);
} else {
// earlier imagick versions have issues with scaling png's
// don't log this because it will just fill the logfile.
// leave this note here so those who are looking for why
// we aren't using imagick can find it
}
}
if(! $ph) {
require_once('include/photo/photo_gd.php');
$ph = new photo_gd($data, $type);
}
return $ph;
}
/**
* @brief Abstract photo driver class.
*
*/
abstract class photo_driver {
protected $image;
protected $width;
protected $height;
protected $valid;
protected $type;
protected $types;
abstract function supportedTypes();
abstract function load($data,$type);
abstract function destroy();
abstract function setDimensions();
abstract function getImage();
abstract function doScaleImage($new_width,$new_height);
abstract function rotate($degrees);
abstract function flip($horiz = true, $vert = false);
abstract function cropImage($max,$x,$y,$w,$h);
abstract function cropImageRect($maxx,$maxy,$x,$y,$w,$h);
abstract function imageString();
abstract function clearexif();
public function __construct($data, $type='') {
$this->types = $this->supportedTypes();
if (! array_key_exists($type, $this->types)){
$type = 'image/jpeg';
}
$this->type = $type;
$this->valid = false;
$this->load($data, $type);
}
public function __destruct() {
if($this->is_valid())
$this->destroy();
}
public function is_valid() {
return $this->valid;
}
public function getWidth() {
if(!$this->is_valid())
return FALSE;
return $this->width;
}
public function getHeight() {
if(!$this->is_valid())
return FALSE;
return $this->height;
}
public function saveImage($path) {
if(!$this->is_valid())
return FALSE;
return (file_put_contents($path, $this->imageString()) ? true : false);
}
public function getType() {
if(!$this->is_valid())
return FALSE;
return $this->type;
}
public function getExt() {
if(!$this->is_valid())
return FALSE;
return $this->types[$this->getType()];
}
/**
* @brief Scale image to max pixel size in either dimension.
*
* @param int $max maximum pixel size in either dimension
* @param boolean $float_height (optional)
* if true allow height to float to any length on tall images, constraining
* only the width
* @return boolean|void false on failure, otherwise void
*/
public function scaleImage($max, $float_height = true) {
if(!$this->is_valid())
return FALSE;
$width = $this->width;
$height = $this->height;
$dest_width = $dest_height = 0;
if((! $width)|| (! $height))
return FALSE;
if($width > $max && $height > $max) {
// very tall image (greater than 16:9)
// constrain the width - let the height float.
if(((($height * 9) / 16) > $width) && ($float_height)) {
$dest_width = $max;
$dest_height = intval(( $height * $max ) / $width);
}
// else constrain both dimensions
elseif($width > $height) {
$dest_width = $max;
$dest_height = intval(( $height * $max ) / $width);
}
else {
$dest_width = intval(( $width * $max ) / $height);
$dest_height = $max;
}
}
else {
if( $width > $max ) {
$dest_width = $max;
$dest_height = intval(( $height * $max ) / $width);
}
else {
if( $height > $max ) {
// very tall image (greater than 16:9)
// but width is OK - don't do anything
if(((($height * 9) / 16) > $width) && ($float_height)) {
$dest_width = $width;
$dest_height = $height;
}
else {
$dest_width = intval(( $width * $max ) / $height);
$dest_height = $max;
}
}
else {
$dest_width = $width;
$dest_height = $height;
}
}
}
$this->doScaleImage($dest_width,$dest_height);
}
public function scaleImageUp($min) {
if(!$this->is_valid())
return FALSE;
$width = $this->width;
$height = $this->height;
$dest_width = $dest_height = 0;
if((! $width)|| (! $height))
return FALSE;
if($width < $min && $height < $min) {
if($width > $height) {
$dest_width = $min;
$dest_height = intval(( $height * $min ) / $width);
}
else {
$dest_width = intval(( $width * $min ) / $height);
$dest_height = $min;
}
}
else {
if( $width < $min ) {
$dest_width = $min;
$dest_height = intval(( $height * $min ) / $width);
}
else {
if( $height < $min ) {
$dest_width = intval(( $width * $min ) / $height);
$dest_height = $min;
}
else {
$dest_width = $width;
$dest_height = $height;
}
}
}
$this->doScaleImage($dest_width,$dest_height);
}
/**
* @brief Scales image to a square.
*
* @param int $dim Pixel of squre image
* @return boolean|void false on failure, otherwise void
*/
public function scaleImageSquare($dim) {
if(!$this->is_valid())
return FALSE;
$this->doScaleImage($dim, $dim);
}
/**
* @brief reads exif data from filename.
*
* @param string $filename
* @return boolean|array
*/
public function exif($filename) {
if((! function_exists('exif_read_data'))
|| (! in_array($this->getType(), [ 'image/jpeg' , 'image/tiff'] ))) {
return false;
}
/*
* PHP 7.2 allows you to use a stream resource, which should reduce/avoid
* memory exhaustion on large images.
*/
if(version_compare(PHP_VERSION, '7.2.0') >= 0) {
$f = @fopen($filename, 'rb');
}
else {
$f = $filename;
}
if($f) {
return @exif_read_data($f,null,true);
}
return false;
}
/**
* @brief Orients current image based on exif orientation information.
*
* @param array $exif
* @return boolean true if oriented, otherwise false
*/
public function orient($exif) {
if(! ($this->is_valid() && $exif)) {
return false;
}
$ort = ((array_key_exists('IFD0', $exif)) ? $exif['IFD0']['Orientation'] : $exif['Orientation']);
if(! $ort) {
return false;
}
switch($ort) {
case 1: // nothing
break;
case 2: // horizontal flip
$this->flip();
break;
case 3: // 180 rotate left
$this->rotate(180);
break;
case 4: // vertical flip
$this->flip(false, true);
break;
case 5: // vertical flip + 90 rotate right
$this->flip(false, true);
$this->rotate(-90);
break;
case 6: // 90 rotate right
$this->rotate(-90);
break;
case 7: // horizontal flip + 90 rotate right
$this->flip();
$this->rotate(-90);
break;
case 8: // 90 rotate left
$this->rotate(90);
break;
default:
break;
}
return true;
}
/**
* @brief Save photo to database.
*
* @param array $arr
* @param boolean $skipcheck (optional) default false
* @return boolean|array
*/
public function save($arr, $skipcheck = false) {
if(! ($skipcheck || $this->is_valid())) {
logger('Attempt to store invalid photo.');
return false;
}
$p = [];
$p['aid'] = ((intval($arr['aid'])) ? intval($arr['aid']) : 0);
$p['uid'] = ((intval($arr['uid'])) ? intval($arr['uid']) : 0);
$p['xchan'] = (($arr['xchan']) ? $arr['xchan'] : '');
$p['resource_id'] = (($arr['resource_id']) ? $arr['resource_id'] : '');
$p['filename'] = (($arr['filename']) ? $arr['filename'] : '');
$p['mimetype'] = (($arr['mimetype']) ? $arr['mimetype'] : $this->getType());
$p['album'] = (($arr['album']) ? $arr['album'] : '');
$p['imgscale'] = ((intval($arr['imgscale'])) ? intval($arr['imgscale']) : 0);
$p['allow_cid'] = (($arr['allow_cid']) ? $arr['allow_cid'] : '');
$p['allow_gid'] = (($arr['allow_gid']) ? $arr['allow_gid'] : '');
$p['deny_cid'] = (($arr['deny_cid']) ? $arr['deny_cid'] : '');
$p['deny_gid'] = (($arr['deny_gid']) ? $arr['deny_gid'] : '');
$p['edited'] = (($arr['edited']) ? $arr['edited'] : datetime_convert());
$p['title'] = (($arr['title']) ? $arr['title'] : '');
$p['description'] = (($arr['description']) ? $arr['description'] : '');
$p['photo_usage'] = intval($arr['photo_usage']);
$p['os_storage'] = intval($arr['os_storage']);
$p['os_path'] = $arr['os_path'];
$p['os_syspath'] = ((array_key_exists('os_syspath',$arr)) ? $arr['os_syspath'] : '');
$p['display_path'] = (($arr['display_path']) ? $arr['display_path'] : '');
$p['width'] = (($arr['width']) ? $arr['width'] : $this->getWidth());
$p['height'] = (($arr['height']) ? $arr['height'] : $this->getHeight());
$p['expires'] = (($arr['expires']) ? $arr['expires'] : gmdate('Y-m-d H:i:s', time() + get_config('system','photo_cache_time', 86400)));
if(! intval($p['imgscale']))
logger('save: ' . print_r($arr, true), LOGGER_DATA);
$x = q("select id, created from photo where resource_id = '%s' and uid = %d and xchan = '%s' and imgscale = %d limit 1",
dbesc($p['resource_id']),
intval($p['uid']),
dbesc($p['xchan']),
intval($p['imgscale'])
);
if($x) {
$p['created'] = (($x['created']) ? $x['created'] : $p['edited']);
$r = q("UPDATE photo set
aid = %d,
uid = %d,
xchan = '%s',
resource_id = '%s',
created = '%s',
edited = '%s',
filename = '%s',
mimetype = '%s',
album = '%s',
height = %d,
width = %d,
content = '%s',
os_storage = %d,
filesize = %d,
imgscale = %d,
photo_usage = %d,
title = '%s',
description = '%s',
os_path = '%s',
display_path = '%s',
allow_cid = '%s',
allow_gid = '%s',
deny_cid = '%s',
deny_gid = '%s',
expires = '%s'
where id = %d",
intval($p['aid']),
intval($p['uid']),
dbesc($p['xchan']),
dbesc($p['resource_id']),
dbescdate($p['created']),
dbescdate($p['edited']),
dbesc(basename($p['filename'])),
dbesc($p['mimetype']),
dbesc($p['album']),
intval($p['height']),
intval($p['width']),
(intval($p['os_storage']) ? dbescbin($p['os_syspath']) : dbescbin($this->imageString())),
intval($p['os_storage']),
(intval($p['os_storage']) ? @filesize($p['os_syspath']) : strlen($this->imageString())),
intval($p['imgscale']),
intval($p['photo_usage']),
dbesc($p['title']),
dbesc($p['description']),
dbesc($p['os_path']),
dbesc($p['display_path']),
dbesc($p['allow_cid']),
dbesc($p['allow_gid']),
dbesc($p['deny_cid']),
dbesc($p['deny_gid']),
dbescdate($p['expires']),
intval($x[0]['id'])
);
}
else {
$p['created'] = (($arr['created']) ? $arr['created'] : $p['edited']);
$r = q("INSERT INTO photo
( aid, uid, xchan, resource_id, created, edited, filename, mimetype, album, height, width, content, os_storage, filesize, imgscale, photo_usage, title, description, os_path, display_path, allow_cid, allow_gid, deny_cid, deny_gid, expires )
VALUES ( %d, %d, '%s', '%s', '%s', '%s', '%s', '%s', '%s', %d, %d, '%s', %d, %d, %d, %d, '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )",
intval($p['aid']),
intval($p['uid']),
dbesc($p['xchan']),
dbesc($p['resource_id']),
dbescdate($p['created']),
dbescdate($p['edited']),
dbesc(basename($p['filename'])),
dbesc($p['mimetype']),
dbesc($p['album']),
intval($p['height']),
intval($p['width']),
(intval($p['os_storage']) ? dbescbin($p['os_syspath']) : dbescbin($this->imageString())),
intval($p['os_storage']),
(intval($p['os_storage']) ? @filesize($p['os_syspath']) : strlen($this->imageString())),
intval($p['imgscale']),
intval($p['photo_usage']),
dbesc($p['title']),
dbesc($p['description']),
dbesc($p['os_path']),
dbesc($p['display_path']),
dbesc($p['allow_cid']),
dbesc($p['allow_gid']),
dbesc($p['deny_cid']),
dbesc($p['deny_gid']),
dbescdate($p['expires'])
);
}
logger('Photo save ' . $p['imgscale'] . ' returned ' . intval($r));
return $r;
}
}
/**
* @brief Guess image mimetype from filename or from Content-Type header.
*
* @param string $filename
* Image filename
* @param string $headers (optional)
* Headers to check for Content-Type (from curl request)
* @return null|string Guessed mimetype
*/
function guess_image_type($filename, $headers = '') {
// logger('Photo: guess_image_type: '.$filename . ($headers?' from curl headers':''), LOGGER_DEBUG);
$type = null;
$m = null;
if($headers) {
$hdrs = [];
$h = explode("\n", $headers);
foreach ($h as $l) {
list($k,$v) = array_map('trim', explode(':', trim($l), 2));
$hdrs[strtolower($k)] = $v;
}
logger('Curl headers: ' .var_export($hdrs, true), LOGGER_DEBUG);
if(array_key_exists('content-type', $hdrs)) {
$ph = photo_factory('');
$types = $ph->supportedTypes();
if(array_key_exists($hdrs['content-type'], $types))
$type = $hdrs['content-type'];
}
}
if(is_null($type)){
$ignore_imagick = get_config('system', 'ignore_imagick');
// Guessing from extension? Isn't that... dangerous?
if(class_exists('Imagick') && file_exists($filename) && is_readable($filename) && !$ignore_imagick) {
$v = Imagick::getVersion();
preg_match('/ImageMagick ([0-9]+\.[0-9]+\.[0-9]+)/', $v['versionString'], $m);
if(version_compare($m[1], '6.6.7') >= 0) {
/**
* Well, this not much better,
* but at least it comes from the data inside the image,
* we won't be tricked by a manipulated extension
*/
$image = new Imagick($filename);
$type = $image->getImageMimeType();
}
else {
// earlier imagick versions have issues with scaling png's
// don't log this because it will just fill the logfile.
// leave this note here so those who are looking for why
// we aren't using imagick can find it
}
}
if(is_null($type)) {
$ext = pathinfo($filename, PATHINFO_EXTENSION);
$ph = photo_factory('');
$types = $ph->supportedTypes();
foreach($types as $m => $e) {
if($ext === $e) {
$type = $m;
}
}
}
if(is_null($type) && (strpos($filename,'http') === false)) {
$size = getimagesize($filename);
$ph = photo_factory('');
$types = $ph->supportedTypes();
$type = ((array_key_exists($size['mime'], $types)) ? $size['mime'] : 'image/jpeg');
}
if(is_null($type)) {
if(strpos(strtolower($filename),'jpg') !== false)
$type = 'image/jpeg';
elseif(strpos(strtolower($filename),'jpeg') !== false)
$type = 'image/jpeg';
elseif(strpos(strtolower($filename),'gif') !== false)
$type = 'image/gif';
elseif(strpos(strtolower($filename),'png') !== false)
$type = 'image/png';
}
}
logger('Photo: guess_image_type: filename = ' . $filename . ' type = ' . $type, LOGGER_DEBUG);
return $type;
}
/**
* @brief Delete thing photo from database.
*
* @param string $url
* @param string $ob_hash
* @return void
*/
function delete_thing_photo($url, $ob_hash) {
$hash = basename($url);
$hash = substr($hash, 0, strpos($hash, '-'));
// hashes should be 32 bytes.
if((! $ob_hash) || (strlen($hash) < 16))
return;
q("delete from photo where xchan = '%s' and photo_usage = %d and resource_id = '%s'",
dbesc($ob_hash),
intval(PHOTO_THING),
dbesc($hash)
);
}
/**
* @brief Fetches a photo from external site and prepares its miniatures.
*
* @param string $photo
* external URL to fetch base image
* @param string $xchan
* channel unique hash
* @param boolean $thing
* TRUE if this is a thing URL
* @param boolean $force
* TRUE if ignore image modification date check (force fetch)
*
* @return array of results
* * \e string \b 0 => local URL to full image
* * \e string \b 1 => local URL to standard thumbnail
* * \e string \b 2 => local URL to micro thumbnail
* * \e string \b 3 => image type
* * \e boolean \b 4 => TRUE if fetch failure
* * \e string \b 5 => modification date
*/
function import_xchan_photo($photo, $xchan, $thing = false, $force = false) {
$modified = '';
$o = null;
$flags = (($thing) ? PHOTO_THING : PHOTO_XCHAN);
$album = (($thing) ? 'Things' : 'Contact Photos');
logger('Updating channel photo from ' . $photo . ' for ' . $xchan, LOGGER_DEBUG);
if($thing) {
$hash = photo_new_resource();
} else {
$r = q("select resource_id, edited, mimetype from photo where xchan = '%s' and photo_usage = %d and imgscale = 4 limit 1", dbesc($xchan), intval(PHOTO_XCHAN));
if($r) {
$hash = $r[0]['resource_id'];
$modified = $r[0]['edited'];
$type = $r[0]['mimetype'];
} else {
$hash = photo_new_resource();
}
}
$photo_failure = false;
$img_str = '';
if($photo) {
if($force || $modified == '') {
$result = z_fetch_url($photo, true);
} else {
$h = [
'headers' => [
'If-Modified-Since: ' . gmdate('D, d M Y H:i:s', strtotime($modified . 'Z')) . ' GMT'
]
];
$result = z_fetch_url($photo, true, 0, $h);
}
if($result['success']) {
$img_str = $result['body'];
$type = guess_image_type($photo, $result['header']);
$modified = gmdate('Y-m-d H:i:s', (preg_match('/last-modified: (.+) \S+/i', $result['header'], $o) ? strtotime($o[1] . 'Z') : time()));
if(is_null($type))
$photo_failure = true;
} elseif($result['return_code'] == 304) {
$photo = z_root() . '/photo/' . $hash . '-4';
$thumb = z_root() . '/photo/' . $hash . '-5';
$micro = z_root() . '/photo/' . $hash . '-6';
} else {
$photo_failure = true;
}
} else {
$photo_failure = true;
}
if(!$photo_failure && $result['return_code'] != 304) {
$img = photo_factory($img_str, $type);
if($img->is_valid()) {
$width = $img->getWidth();
$height = $img->getHeight();
if($width && $height) {
if(($width / $height) > 1.2) {
// crop out the sides
$margin = $width - $height;
$img->cropImage(300, ($margin / 2), 0, $height, $height);
} elseif(($height / $width) > 1.2) {
// crop out the bottom
$margin = $height - $width;
$img->cropImage(300, 0, 0, $width, $width);
} else {
$img->scaleImageSquare(300);
}
} else {
$photo_failure = true;
}
$p = [
'xchan' => $xchan,
'resource_id' => $hash,
'filename' => basename($photo),
'album' => $album,
'photo_usage' => $flags,
'imgscale' => 4,
'edited' => $modified,
];
$r = $img->save($p);
if($r === false)
$photo_failure = true;
$img->scaleImage(80);
$p['imgscale'] = 5;
$r = $img->save($p);
if($r === false)
$photo_failure = true;
$img->scaleImage(48);
$p['imgscale'] = 6;
$r = $img->save($p);
if($r === false)
$photo_failure = true;
$photo = z_root() . '/photo/' . $hash . '-4';
$thumb = z_root() . '/photo/' . $hash . '-5';
$micro = z_root() . '/photo/' . $hash . '-6';
} else {
logger('Invalid image from ' . $photo);
$photo_failure = true;
}
}
if($photo_failure) {
$default = get_default_profile_photo();
$photo = z_root() . '/' . $default;
$thumb = z_root() . '/' . get_default_profile_photo(80);
$micro = z_root() . '/' . get_default_profile_photo(48);
$type = 'image/png';
$modified = gmdate('Y-m-d H:i:s', filemtime($default));
}
logger('HTTP code: ' . $result['return_code'] . '; modified: ' . $modified
. '; failure: ' . ($photo_failure ? 'yes' : 'no') . '; URL: ' . $photo, LOGGER_DEBUG);
return(array($photo,$thumb,$micro,$type,$photo_failure,$modified));
}
/**
* @brief Import channel photo from a URL.
*
* @param string $photo URL to a photo
* @param int $aid
* @param int $uid channel_id
* @return null|string Guessed image mimetype or null.
*/
function import_channel_photo_from_url($photo, $aid, $uid) {
$type = null;
if($photo) {
$result = z_fetch_url($photo, true);
if($result['success']) {
$img_str = $result['body'];
$type = guess_image_type($photo, $result['header']);
import_channel_photo($img_str, $type, $aid, $uid);
}
}
return $type;
}
/**
* @brief Import a channel photo and prepare its miniatures.
*
* @param string $photo Image data
* @param string $type
* @param int $aid
* @param int $uid channel_id
* @return boolean|string false on failure, otherwise resource_id of photo
*/
function import_channel_photo($photo, $type, $aid, $uid) {
logger('Importing channel photo for ' . $uid, LOGGER_DEBUG);
$photo_failure = false;
$hash = photo_new_resource();
$filename = $hash;
$img = photo_factory($photo, $type);
if($img->is_valid()) {
// config array for image save method
$p = [
'aid' => $aid,
'uid' => $uid,
'resource_id' => $hash,
'filename' => $filename,
'album' => t('Profile Photos'),
'photo_usage' => PHOTO_PROFILE,
'imgscale' => 4,
];
// photo size
$img->scaleImageSquare(300);
$r = $img->save($p);
if($r === false)
$photo_failure = true;
// thumb size
$img->scaleImage(80);
$p['imgscale'] = 5;
$r = $img->save($p);
if($r === false)
$photo_failure = true;
// micro size
$img->scaleImage(48);
$p['imgscale'] = 6;
$r = $img->save($p);
if($r === false)
$photo_failure = true;
} else {
logger('Invalid image.');
$photo_failure = true;
}
if($photo_failure)
return false;
else
return $hash;
}