define('INLINEIMAGE_DOWNLOAD_URL', 'pages/ajax.document.php?operation=download_inlineimage&id=');
/**
* Persistent classes (internal): store images referenced inside HTML formatted text fields
*
* @copyright Copyright (C) 2016 Combodo SARL
* @license http://opensource.org/licenses/AGPL-3.0
*/
class InlineImage extends DBObject
{
/** @var string attribute to be added to IMG tags to contain ID */
const DOM_ATTR_ID = 'data-img-id';
/** @var string attribute to be added to IMG tags to contain secret */
const DOM_ATTR_SECRET = 'data-img-secret';
public static function Init()
{
$aParams = array
(
'category' => 'addon',
'key_type' => 'autoincrement',
'name_attcode' => array('item_class', 'temp_id'),
'state_attcode' => '',
'reconc_keys' => array(''),
'db_table' => 'inline_image',
'db_key_field' => 'id',
'db_finalclass_field' => '',
'indexes' => array(
array('temp_id'),
array('item_class', 'item_id'),
array('item_org_id'),
),
);
MetaModel::Init_Params($aParams);
MetaModel::Init_InheritAttributes();
MetaModel::Init_AddAttribute(new AttributeDateTime("expire", array("allowed_values"=>null, "sql"=>'expire', "default_value"=>'', "is_null_allowed"=>false, "depends_on"=>array(), "always_load_in_tables"=>false)));
MetaModel::Init_AddAttribute(new AttributeString("temp_id", array("allowed_values"=>null, "sql"=>'temp_id', "default_value"=>'', "is_null_allowed"=>true, "depends_on"=>array(), "always_load_in_tables"=>false)));
MetaModel::Init_AddAttribute(new AttributeString("item_class", array("allowed_values"=>null, "sql"=>'item_class', "default_value"=>'', "is_null_allowed"=>false, "depends_on"=>array(), "always_load_in_tables"=>false)));
MetaModel::Init_AddAttribute(new AttributeObjectKey("item_id", array("class_attcode"=>'item_class', "allowed_values"=>null, "sql"=>'item_id', "is_null_allowed"=>true, "depends_on"=>array(), "always_load_in_tables"=>false)));
MetaModel::Init_AddAttribute(new AttributeInteger("item_org_id", array("allowed_values"=>null, "sql"=>'item_org_id', "default_value"=>'0', "is_null_allowed"=>true, "depends_on"=>array(), "always_load_in_tables"=>false)));
MetaModel::Init_AddAttribute(new AttributeBlob("contents", array("is_null_allowed"=>false, "depends_on"=>array(), "always_load_in_tables"=>false)));
MetaModel::Init_AddAttribute(new AttributeString("secret", array("allowed_values"=>null, "sql" => "secret", "default_value"=>'', "is_null_allowed"=>false, "depends_on"=>array(), "always_load_in_tables"=>false)));
MetaModel::Init_SetZListItems('details', array('temp_id', 'item_class', 'item_id', 'item_org_id'));
MetaModel::Init_SetZListItems('standard_search', array('temp_id', 'item_class', 'item_id'));
MetaModel::Init_SetZListItems('list', array('temp_id', 'item_class', 'item_id' ));
}
/**
* Maps the given context parameter name to the appropriate filter/search code for this class
* @param string $sContextParam Name of the context parameter, e.g. 'org_id'
* @return string Filter code, e.g. 'customer_id'
*/
public static function MapContextParam($sContextParam)
{
if ($sContextParam == 'org_id')
{
return 'item_org_id';
}
else
{
return null;
}
}
/**
* Set/Update all of the '_item' fields
* @param DBObject $oItem Container item
* @return void
*/
public function SetItem(DBObject $oItem, $bUpdateOnChange = false)
{
$sClass = get_class($oItem);
$iItemId = $oItem->GetKey();
$this->Set('item_class', $sClass);
$this->Set('item_id', $iItemId);
$aCallSpec = array($sClass, 'MapContextParam');
if (is_callable($aCallSpec))
{
$sAttCode = call_user_func($aCallSpec, 'org_id'); // Returns null when there is no mapping for this parameter
if (MetaModel::IsValidAttCode($sClass, $sAttCode))
{
$iOrgId = $oItem->Get($sAttCode);
if ($iOrgId > 0)
{
if ($iOrgId != $this->Get('item_org_id'))
{
$this->Set('item_org_id', $iOrgId);
if ($bUpdateOnChange)
{
$this->DBUpdate();
}
}
}
}
}
}
/**
* Give a default value for item_org_id (if relevant...)
* @return void
*/
public function SetDefaultOrgId()
{
// First check that the organization CAN be fetched from the target class
//
$sClass = $this->Get('item_class');
$aCallSpec = array($sClass, 'MapContextParam');
if (is_callable($aCallSpec))
{
$sAttCode = call_user_func($aCallSpec, 'org_id'); // Returns null when there is no mapping for this parameter
if (MetaModel::IsValidAttCode($sClass, $sAttCode))
{
// Second: check that the organization CAN be fetched from the current user
//
if (MetaModel::IsValidClass('Person'))
{
$aCallSpec = array($sClass, 'MapContextParam');
if (is_callable($aCallSpec))
{
$sAttCode = call_user_func($aCallSpec, 'org_id'); // Returns null when there is no mapping for this parameter
if (MetaModel::IsValidAttCode($sClass, $sAttCode))
{
// OK - try it
//
$oCurrentPerson = MetaModel::GetObject('Person', UserRights::GetContactId(), false);
if ($oCurrentPerson)
{
$this->Set('item_org_id', $oCurrentPerson->Get($sAttCode));
}
}
}
}
}
}
}
/**
* When posting a form, finalize the creation of the inline images
* related to the specified object
*
* @param DBObject $oObject
*/
public static function FinalizeInlineImages(DBObject $oObject)
{
$iTransactionId = utils::ReadParam('transaction_id', null, false, 'transaction_id');
if (!is_null($iTransactionId))
{
// Attach new (temporary) inline images
$sTempId = utils::GetUploadTempId($iTransactionId);
// The object is being created from a form, check if there are pending inline images for this object
$sOQL = 'SELECT InlineImage WHERE temp_id = :temp_id';
$oSearch = DBObjectSearch::FromOQL($sOQL);
$oSet = new DBObjectSet($oSearch, array(), array('temp_id' => $sTempId));
while($oInlineImage = $oSet->Fetch())
{
$oInlineImage->SetItem($oObject);
$oInlineImage->Set('temp_id', '');
$oInlineImage->DBUpdate();
}
}
// For tracing issues with Inline Images... but beware not all updates are interactive, so this trace happens when creating objects non-interactively (REST, Synchro...)
// else
// {
// IssueLog::Error('InlineImage: Error during FinalizeInlineImages(), no transaction ID for object '.get_class($oObject).'#'.$oObject->GetKey().'.');
//
// IssueLog::Error('|- Call stack:');
// $oException = new Exception();
// $sStackTrace = $oException->getTraceAsString();
// IssueLog::Error($sStackTrace);
//
// IssueLog::Error('|- POST vars:');
// IssueLog::Error(print_r($_POST, true));
// }
}
/**
* Cleanup the pending images if the form is not submitted
* @param string $sTempId
*/
public static function OnFormCancel($sTempId)
{
// Delete all "pending" InlineImages for this form
$sOQL = 'SELECT InlineImage WHERE temp_id = :temp_id';
$oSearch = DBObjectSearch::FromOQL($sOQL);
$oSet = new DBObjectSet($oSearch, array(), array('temp_id' => $sTempId));
while($oInlineImage = $oSet->Fetch())
{
$oInlineImage->DBDelete();
}
}
/**
* Parses the supplied HTML fragment to rebuild the attribute src="" for images
* that refer to an InlineImage (detected via the attribute data-img-id="") so that
* the URL is consistent with the current URL of the application.
* @param string $sHtml The HTML fragment to process
* @return string The modified HTML
*/
public static function FixUrls($sHtml)
{
$aNeedles = array();
$aReplacements = array();
// Find img tags with an attribute data-img-id
if (preg_match_all('/]*)'.self::DOM_ATTR_ID.'="([0-9]+)"([^>]*)>/i',
$sHtml, $aMatches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE))
{
$sUrl = utils::GetAbsoluteUrlAppRoot().INLINEIMAGE_DOWNLOAD_URL;
foreach($aMatches as $aImgInfo)
{
$sImgTag = $aImgInfo[0][0];
$sSecret = '';
if (preg_match('/data-img-secret="([0-9a-f]+)"/', $sImgTag, $aSecretMatches))
{
$sSecret = '&s='.$aSecretMatches[1];
}
$sAttId = $aImgInfo[2][0];
$sNewImgTag = preg_replace('/src="[^"]+"/', 'src="'.htmlentities($sUrl.$sAttId.$sSecret, ENT_QUOTES, 'UTF-8').'"', $sImgTag); // preserve other attributes, must convert & to & to be idempotent with CKEditor
$aNeedles[] = $sImgTag;
$aReplacements[] = $sNewImgTag;
}
$sHtml = str_replace($aNeedles, $aReplacements, $sHtml);
}
return $sHtml;
}
/**
* Add an extra attribute data-img-id for images which are based on an actual InlineImage
* so that we can later reconstruct the full "src" URL when needed
*
* @param \DOMElement $oElement
*/
public static function ProcessImageTag(DOMElement $oElement)
{
$sSrc = $oElement->getAttribute('src');
$sDownloadUrl = str_replace(array('.', '?'), array('\.', '\?'), INLINEIMAGE_DOWNLOAD_URL); // Escape . and ?
$sUrlPattern = '|'.$sDownloadUrl.'([0-9]+)&s=([0-9a-f]+)|';
$bIsInlineImage = preg_match($sUrlPattern, $sSrc, $aMatches);
if (!$bIsInlineImage)
{
return;
}
$iInlineImageId = $aMatches[1];
$sInlineIMageSecret = $aMatches[2];
$sAppRoot = utils::GetAbsoluteUrlAppRoot();
$sAppRootPattern = '/^'.preg_quote($sAppRoot, '/').'/';
$bIsSameItop = preg_match($sAppRootPattern, $sSrc);
if (!$bIsSameItop)
{
// @see N°1921
// image from another iTop should be treated as external images
$oElement->removeAttribute(self::DOM_ATTR_ID);
$oElement->removeAttribute(self::DOM_ATTR_SECRET);
return;
}
$oElement->setAttribute(self::DOM_ATTR_ID, $iInlineImageId);
$oElement->setAttribute(self::DOM_ATTR_SECRET, $sInlineIMageSecret);
}
/**
* Get the javascript fragment - to be added to "on document ready" - to adjust (on the fly) the width on Inline Images
*/
public static function FixImagesWidth()
{
$iMaxWidth = (int)MetaModel::GetConfig()->Get('inline_image_max_display_width', 0);
$sJS = '';
if ($iMaxWidth != 0)
{
$sJS =
<< $iMaxWidth)
{
$(this).css({'max-width': '{$iMaxWidth}px', width: '', height: '', 'max-height': ''});
}
$(this).addClass('inline-image').attr('href', $(this).attr('src'));
}).magnificPopup({type: 'image', closeOnContentClick: true });
EOF
;
}
return $sJS;
}
/**
* Check if an the given mimeType is an image that can be processed by the system
*
* @param string $sMimeType
*
* @return boolean always false if php-gd not installed
* otherwise true if file is one of those type : image/gif, image/jpeg, image/png
* @uses php-gd extension
*/
public static function IsImage($sMimeType)
{
if (!function_exists('gd_info')) return false; // no image processing capability on this system
$bRet = false;
$aInfo = gd_info(); // What are the capabilities
switch($sMimeType)
{
case 'image/gif':
return $aInfo['GIF Read Support'];
break;
case 'image/jpeg':
return $aInfo['JPEG Support'];
break;
case 'image/png':
return $aInfo['PNG Support'];
break;
}
return $bRet;
}
/**
* Resize an image so that it fits the maximum width/height defined in the config file
* @param ormDocument $oImage The original image stored as an array (content / mimetype / filename)
* @return ormDocument The resampled image (or the original one if it already fit)
*/
public static function ResizeImageToFit(ormDocument $oImage, &$aDimensions = null)
{
$img = false;
switch($oImage->GetMimeType())
{
case 'image/gif':
case 'image/jpeg':
case 'image/png':
$img = @imagecreatefromstring($oImage->GetData());
break;
default:
// Unsupported image type, return the image as-is
$aDimensions = null;
return $oImage;
}
if ($img === false)
{
$aDimensions = null;
return $oImage;
}
else
{
// Let's scale the image, preserving the transparency for GIFs and PNGs
$iWidth = imagesx($img);
$iHeight = imagesy($img);
$aDimensions = array('width' => $iWidth, 'height' => $iHeight);
$iMaxImageSize = (int)MetaModel::GetConfig()->Get('inline_image_max_storage_width', 0);
if (($iMaxImageSize > 0) && ($iWidth <= $iMaxImageSize) && ($iHeight <= $iMaxImageSize))
{
// No need to resize
return $oImage;
}
$fScale = min($iMaxImageSize / $iWidth, $iMaxImageSize / $iHeight);
$iNewWidth = $iWidth * $fScale;
$iNewHeight = $iHeight * $fScale;
$aDimensions['width'] = $iNewWidth;
$aDimensions['height'] = $iNewHeight;
$new = imagecreatetruecolor($iNewWidth, $iNewHeight);
// Preserve transparency
if(($oImage->GetMimeType() == "image/gif") || ($oImage->GetMimeType() == "image/png"))
{
imagecolortransparent($new, imagecolorallocatealpha($new, 0, 0, 0, 127));
imagealphablending($new, false);
imagesavealpha($new, true);
}
imagecopyresampled($new, $img, 0, 0, 0, 0, $iNewWidth, $iNewHeight, $iWidth, $iHeight);
ob_start();
switch ($oImage->GetMimeType())
{
case 'image/gif':
imagegif($new); // send image to output buffer
break;
case 'image/jpeg':
imagejpeg($new, null, 80); // null = send image to output buffer, 80 = good quality
break;
case 'image/png':
imagepng($new, null, 5); // null = send image to output buffer, 5 = medium compression
break;
}
$oNewImage = new ormDocument(ob_get_contents(), $oImage->GetMimeType(), $oImage->GetFileName());
@ob_end_clean();
imagedestroy($img);
imagedestroy($new);
return $oNewImage;
}
}
/**
* Get the (localized) textual representation of the max upload size
* @return string
*/
public static function GetMaxUpload()
{
$iMaxUpload = ini_get('upload_max_filesize');
if (!$iMaxUpload)
{
$sRet = Dict::S('Attachments:UploadNotAllowedOnThisSystem');
}
else
{
$iMaxUpload = utils::ConvertToBytes($iMaxUpload);
if ($iMaxUpload > 1024*1024*1024)
{
$sRet = Dict::Format('Attachment:Max_Go', sprintf('%0.2f', $iMaxUpload/(1024*1024*1024)));
}
else if ($iMaxUpload > 1024*1024)
{
$sRet = Dict::Format('Attachment:Max_Mo', sprintf('%0.2f', $iMaxUpload/(1024*1024)));
}
else
{
$sRet = Dict::Format('Attachment:Max_Ko', sprintf('%0.2f', $iMaxUpload/(1024)));
}
}
return $sRet;
}
/**
* Get the fragment of javascript needed to complete the initialization of
* CKEditor when creating/modifying an object
*
* @param \DBObject $oObject The object being edited
* @param string $sTempId Generated through utils::GetUploadTempId($iTransactionId)
*
* @return string The JS fragment to insert in "on document ready"
* @throws \Exception
*/
public static function EnableCKEditorImageUpload(DBObject $oObject, $sTempId)
{
$sObjClass = get_class($oObject);
$iObjKey = $oObject->GetKey();
$sAbsoluteUrlAppRoot = utils::GetAbsoluteUrlAppRoot();
$sToggleFullScreen = htmlentities(Dict::S('UI:ToggleFullScreen'), ENT_QUOTES, 'UTF-8');
$sAppRootUrl = utils::GetAbsoluteUrlAppRoot();
return
<< ');
$('#'+oEditor.id+'_toolbox .editor_magnifier').on('click', function() {
oEditor.execCommand('maximize');
if ($(this).closest('.cke_maximized').length != 0)
{
$('#'+oEditor.id+'_toolbar_collapser').trigger('click');
}
});
}
if (oEditor.widgets.registered.uploadimage)
{
oEditor.widgets.registered.uploadimage.onUploaded = function( upload ) {
var oData = JSON.parse(upload.xhr.responseText);
this.replaceWith( '' );
}
}
});
});
EOF
;
}
}
/**
* Garbage collector for cleaning "old" temporary InlineImages (and Attachments).
* This background process runs every hour and deletes all temporary InlineImages and Attachments
* whic are are older than one hour.
*/
class InlineImageGC implements iBackgroundProcess
{
public function GetPeriodicity()
{
return 1; // Runs every 8 hours
}
/**
* @param int $iTimeLimit
*
* @return string
* @throws \CoreException
* @throws \CoreUnexpectedValue
* @throws \DeleteException
* @throws \MySQLException
* @throws \OQLException
*/
public function Process($iTimeLimit)
{
$sDateLimit = date(AttributeDateTime::GetSQLFormat(), time()); // Every temporary InlineImage/Attachment expired will be deleted
$aResults = array();
$aClasses = array('InlineImage', 'Attachment');
foreach($aClasses as $sClass)
{
$iProcessed = 0;
if(class_exists($sClass))
{
$iProcessed = $this->DeleteExpiredDocuments($sClass, $iTimeLimit, $sDateLimit);
}
$aResults[] = "$iProcessed old temporary $sClass(s)";
}
return "Cleaned ".implode(' and ', $aResults).".";
}
/**
* @param string $sClass
* @param int $iTimeLimit
* @param string $sDateLimit
*
* @return int
* @throws \CoreException
* @throws \CoreUnexpectedValue
* @throws \DeleteException
* @throws \MySQLException
* @throws \OQLException
*/
protected function DeleteExpiredDocuments($sClass, $iTimeLimit, $sDateLimit)
{
$iProcessed = 0;
$sOQL = "SELECT $sClass WHERE (item_id = 0) AND (expire < '$sDateLimit')";
// Next one ?
$oSet = new CMDBObjectSet(DBObjectSearch::FromOQL($sOQL), array('expire' => true) /* order by*/, array(), null,
1 /* limit count */);
$oSet->OptimizeColumnLoad(array());
while ((time() < $iTimeLimit) && ($oResult = $oSet->Fetch()))
{
/** @var \ormDocument $oDocument */
$oDocument = $oResult->Get('contents');
IssueLog::Info($sClass.' GC: Removed temp. file '.$oDocument->GetFileName().' on "'.$oResult->Get('item_class').'" #'.$oResult->Get('item_id').' as it has expired.');
$oResult->DBDelete();
$iProcessed++;
}
return $iProcessed;
}
}