'addon', 'key_type' => 'autoincrement', 'name_attcode' => ['item_class', 'temp_id'], 'state_attcode' => '', 'reconc_keys' => [''], 'db_table' => 'inline_image', 'db_key_field' => 'id', 'db_finalclass_field' => '', 'indexes' => [ ['temp_id'], ['item_class', 'item_id'], ['item_org_id'], ], ]; MetaModel::Init_Params($aParams); MetaModel::Init_InheritAttributes(); MetaModel::Init_AddAttribute(new AttributeDateTime("expire", ["allowed_values" => null, "sql" => 'expire', "default_value" => 'DATE_ADD(NOW(), INTERVAL 1 DAY)', "is_null_allowed" => false, "depends_on" => [], "always_load_in_tables" => false])); MetaModel::Init_AddAttribute(new AttributeString("temp_id", ["allowed_values" => null, "sql" => 'temp_id', "default_value" => '', "is_null_allowed" => true, "depends_on" => [], "always_load_in_tables" => false])); MetaModel::Init_AddAttribute(new AttributeString("item_class", ["allowed_values" => null, "sql" => 'item_class', "default_value" => '', "is_null_allowed" => false, "depends_on" => [], "always_load_in_tables" => false])); MetaModel::Init_AddAttribute(new AttributeObjectKey("item_id", ["class_attcode" => 'item_class', "allowed_values" => null, "sql" => 'item_id', "is_null_allowed" => true, "depends_on" => [], "always_load_in_tables" => false])); MetaModel::Init_AddAttribute(new AttributeInteger("item_org_id", ["allowed_values" => null, "sql" => 'item_org_id', "default_value" => '0', "is_null_allowed" => true, "depends_on" => [], "always_load_in_tables" => false])); MetaModel::Init_AddAttribute(new AttributeBlob("contents", ["is_null_allowed" => false, "depends_on" => [], "always_load_in_tables" => false])); MetaModel::Init_AddAttribute(new AttributeString("secret", ["allowed_values" => null, "sql" => "secret", "default_value" => '', "is_null_allowed" => false, "depends_on" => [], "always_load_in_tables" => false])); MetaModel::Init_SetZListItems('details', ['temp_id', 'item_class', 'item_id', 'item_org_id']); MetaModel::Init_SetZListItems('standard_search', ['temp_id', 'item_class', 'item_id']); MetaModel::Init_SetZListItems('list', ['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|null 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 * @param bool $bUpdateOnChange * * @return void * @throws \ArchivedObjectException * @throws \CoreCannotSaveObjectException * @throws \CoreException * @throws \CoreUnexpectedValue */ 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 = [$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 * @throws \ArchivedObjectException * @throws \CoreException * @throws \CoreUnexpectedValue * @throws \Exception */ public function SetDefaultOrgId() { // First check that the organization CAN be fetched from the target class // $sClass = $this->Get('item_class'); $aCallSpec = [$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 = [$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 * * @return void * @throws \CoreCannotSaveObjectException * @throws \CoreException * @throws \CoreUnexpectedValue * @throws \MySQLException * @throws \OQLException */ 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, [], ['temp_id' => $sTempId]); $aInlineImagesId = []; while ($oInlineImage = $oSet->Fetch()) { $aInlineImagesId[] = $oInlineImage->GetKey(); $oInlineImage->SetItem($oObject); $oInlineImage->Set('temp_id', ''); $oInlineImage->DBUpdate(); } IssueLog::Trace('FinalizeInlineImages (see $aInlineImagesId for the id list)', LogChannels::INLINE_IMAGE, [ '$sObjectClass' => get_class($oObject), '$sTransactionId' => $iTransactionId, '$sTempId' => $sTempId, '$aInlineImagesId' => $aInlineImagesId, '$sUser' => UserRights::GetUser(), 'HTTP_REFERER' => @$_SERVER['HTTP_REFERER'], ]); } else { IssueLog::Trace('FinalizeInlineImages "error" $iTransactionId is null', LogChannels::INLINE_IMAGE, [ '$sObjectClass' => get_class($oObject), '$sTransactionId' => $iTransactionId, '$sUser' => UserRights::GetUser(), 'HTTP_REFERER' => @$_SERVER['HTTP_REFERER'], ]); } } /** * Cleanup the pending images if the form is not submitted * * @param string $sTempId * * @return bool True if cleaning was successful, false if anything aborted it * @throws \ArchivedObjectException * @throws \CoreCannotSaveObjectException * @throws \CoreException * @throws \CoreUnexpectedValue * @throws \DeleteException * @throws \MySQLException * @throws \MySQLHasGoneAwayException * @throws \OQLException */ public static function OnFormCancel($sTempId): bool { // Protection against unfortunate massive delete of inline images when a null temp ID is passed if (utils::IsNullOrEmptyString($sTempId)) { IssueLog::Trace('OnFormCancel "error" $sTempId is null or empty', LogChannels::INLINE_IMAGE, [ '$sTempId' => $sTempId, '$sUser' => UserRights::GetUser(), 'HTTP_REFERER' => @$_SERVER['HTTP_REFERER'], ]); return false; } // Delete all "pending" InlineImages for this form $sOQL = 'SELECT InlineImage WHERE temp_id = :temp_id'; $oSearch = DBObjectSearch::FromOQL($sOQL); $oSet = new DBObjectSet($oSearch, [], ['temp_id' => $sTempId]); $aInlineImagesId = []; while ($oInlineImage = $oSet->Fetch()) { $aInlineImagesId[] = $oInlineImage->GetKey(); $oInlineImage->DBDelete(); } IssueLog::Trace('OnFormCancel', LogChannels::INLINE_IMAGE, [ '$sTempId' => $sTempId, '$aInlineImagesId' => $aInlineImagesId, '$sUser' => UserRights::GetUser(), 'HTTP_REFERER' => @$_SERVER['HTTP_REFERER'], ]); return true; } /** * 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 * @throws \Exception */ public static function FixUrls($sHtml) { $aNeedles = []; $aReplacements = []; // 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="'.utils::EscapeHtml($sUrl.$sAttId.$sSecret).'"', $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 * * @return void * @throws \Exception */ public static function ProcessImageTag(DOMElement $oElement) { $sSrc = $oElement->getAttribute('src'); $sDownloadUrl = str_replace(['.', '?'], ['\.', '\?'], 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 * * @return string */ public static function FixImagesWidth() { $iMaxWidth = (int)MetaModel::GetConfig()->Get('inline_image_max_display_width', 0); $sJS = ''; if ($iMaxWidth != 0) { $sJS = <<Get('inline_image_max_storage_width', 0); return $oImage->ResizeImageToFit($iMaxImageSize, $iMaxImageSize, $aDimensions); } /** * 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))); } elseif ($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 = utils::EscapeHtml(Dict::S('UI:ToggleFullScreen')); return << $this->GetKey(), 'expire' => $this->Get('expire'), 'temp_id' => $this->Get('temp_id'), 'item_class' => $this->Get('item_class'), 'item_id' => $this->Get('item_id'), 'item_org_id' => $this->Get('item_org_id'), 'secret' => $this->Get('secret'), 'user' => $sUser = UserRights::GetUser(), 'HTTP_REFERER' => @$_SERVER['HTTP_REFERER'], 'REQUEST_URI' => @$_SERVER['REQUEST_URI'], ]); parent::AfterInsert(); } /** * @inheritDoc */ protected function AfterUpdate() { IssueLog::Trace(__METHOD__, LogChannels::INLINE_IMAGE, [ 'id' => $this->GetKey(), 'expire' => $this->Get('expire'), 'temp_id' => $this->Get('temp_id'), 'item_class' => $this->Get('item_class'), 'item_id' => $this->Get('item_id'), 'item_org_id' => $this->Get('item_org_id'), 'secret' => $this->Get('secret'), 'user' => $sUser = UserRights::GetUser(), 'HTTP_REFERER' => @$_SERVER['HTTP_REFERER'], 'REQUEST_URI' => @$_SERVER['REQUEST_URI'], ]); parent::AfterUpdate(); } /** * @inheritDoc */ protected function AfterDelete() { IssueLog::Trace(__METHOD__, LogChannels::INLINE_IMAGE, [ 'id' => $this->GetKey(), 'expire' => $this->Get('expire'), 'temp_id' => $this->Get('temp_id'), 'item_class' => $this->Get('item_class'), 'item_id' => $this->Get('item_id'), 'item_org_id' => $this->Get('item_org_id'), 'secret' => $this->Get('secret'), 'user' => $sUser = UserRights::GetUser(), 'HTTP_REFERER' => @$_SERVER['HTTP_REFERER'], 'REQUEST_URI' => @$_SERVER['REQUEST_URI'], ]); parent::AfterDelete(); } } /** * Garbage collector for cleaning "old" temporary InlineImages (and Attachments). */ class InlineImageGC implements iBackgroundProcess { /** * @inheritDoc */ public function GetPeriodicity() { return 1; } /** * @inheritDoc */ public function Process($iTimeLimit) { $sDateLimit = date(AttributeDateTime::GetSQLFormat(), time()); // Every temporary InlineImage/Attachment expired will be deleted $aResults = []; $aClasses = ['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)."."; } /** * Remove $sClass instance based on their `expire` field value. * This `expire` field contains current time + draft_attachments_lifetime config parameter, it is initialized on object creation. * * @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), ['expire' => true] /* order by*/, [], null, 1 /* limit count */ ); $oSet->OptimizeColumnLoad([]); 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; } }