inlineimage.class.inc.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. <?php
  2. // Copyright (C) 2016 Combodo SARL
  3. //
  4. // This file is part of iTop.
  5. //
  6. // iTop is free software; you can redistribute it and/or modify
  7. // it under the terms of the GNU Affero General Public License as published by
  8. // the Free Software Foundation, either version 3 of the License, or
  9. // (at your option) any later version.
  10. //
  11. // iTop is distributed in the hope that it will be useful,
  12. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. // GNU Affero General Public License for more details.
  15. //
  16. // You should have received a copy of the GNU Affero General Public License
  17. // along with iTop. If not, see <http://www.gnu.org/licenses/>
  18. define('INLINEIMAGE_DOWNLOAD_URL', 'pages/ajax.document.php?operation=download_inlineimage&id=');
  19. /**
  20. * Persistent classes (internal): store images referenced inside HTML formatted text fields
  21. *
  22. * @copyright Copyright (C) 2016 Combodo SARL
  23. * @license http://opensource.org/licenses/AGPL-3.0
  24. */
  25. class InlineImage extends DBObject
  26. {
  27. public static function Init()
  28. {
  29. $aParams = array
  30. (
  31. 'category' => 'addon',
  32. 'key_type' => 'autoincrement',
  33. 'name_attcode' => array('item_class', 'temp_id'),
  34. 'state_attcode' => '',
  35. 'reconc_keys' => array(''),
  36. 'db_table' => 'inline_image',
  37. 'db_key_field' => 'id',
  38. 'db_finalclass_field' => '',
  39. 'indexes' => array(
  40. array('temp_id'),
  41. array('item_class', 'item_id'),
  42. array('item_org_id'),
  43. ),
  44. );
  45. MetaModel::Init_Params($aParams);
  46. MetaModel::Init_InheritAttributes();
  47. 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)));
  48. 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)));
  49. 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)));
  50. 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)));
  51. 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)));
  52. MetaModel::Init_AddAttribute(new AttributeBlob("contents", array("is_null_allowed"=>false, "depends_on"=>array(), "always_load_in_tables"=>false)));
  53. 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)));
  54. MetaModel::Init_SetZListItems('details', array('temp_id', 'item_class', 'item_id', 'item_org_id'));
  55. MetaModel::Init_SetZListItems('standard_search', array('temp_id', 'item_class', 'item_id'));
  56. MetaModel::Init_SetZListItems('list', array('temp_id', 'item_class', 'item_id' ));
  57. }
  58. /**
  59. * Maps the given context parameter name to the appropriate filter/search code for this class
  60. * @param string $sContextParam Name of the context parameter, e.g. 'org_id'
  61. * @return string Filter code, e.g. 'customer_id'
  62. */
  63. public static function MapContextParam($sContextParam)
  64. {
  65. if ($sContextParam == 'org_id')
  66. {
  67. return 'item_org_id';
  68. }
  69. else
  70. {
  71. return null;
  72. }
  73. }
  74. /**
  75. * Set/Update all of the '_item' fields
  76. * @param DBObject $oItem Container item
  77. * @return void
  78. */
  79. public function SetItem(DBObject $oItem, $bUpdateOnChange = false)
  80. {
  81. $sClass = get_class($oItem);
  82. $iItemId = $oItem->GetKey();
  83. $this->Set('item_class', $sClass);
  84. $this->Set('item_id', $iItemId);
  85. $aCallSpec = array($sClass, 'MapContextParam');
  86. if (is_callable($aCallSpec))
  87. {
  88. $sAttCode = call_user_func($aCallSpec, 'org_id'); // Returns null when there is no mapping for this parameter
  89. if (MetaModel::IsValidAttCode($sClass, $sAttCode))
  90. {
  91. $iOrgId = $oItem->Get($sAttCode);
  92. if ($iOrgId > 0)
  93. {
  94. if ($iOrgId != $this->Get('item_org_id'))
  95. {
  96. $this->Set('item_org_id', $iOrgId);
  97. if ($bUpdateOnChange)
  98. {
  99. $this->DBUpdate();
  100. }
  101. }
  102. }
  103. }
  104. }
  105. }
  106. /**
  107. * Give a default value for item_org_id (if relevant...)
  108. * @return void
  109. */
  110. public function SetDefaultOrgId()
  111. {
  112. // First check that the organization CAN be fetched from the target class
  113. //
  114. $sClass = $this->Get('item_class');
  115. $aCallSpec = array($sClass, 'MapContextParam');
  116. if (is_callable($aCallSpec))
  117. {
  118. $sAttCode = call_user_func($aCallSpec, 'org_id'); // Returns null when there is no mapping for this parameter
  119. if (MetaModel::IsValidAttCode($sClass, $sAttCode))
  120. {
  121. // Second: check that the organization CAN be fetched from the current user
  122. //
  123. if (MetaModel::IsValidClass('Person'))
  124. {
  125. $aCallSpec = array($sClass, 'MapContextParam');
  126. if (is_callable($aCallSpec))
  127. {
  128. $sAttCode = call_user_func($aCallSpec, 'org_id'); // Returns null when there is no mapping for this parameter
  129. if (MetaModel::IsValidAttCode($sClass, $sAttCode))
  130. {
  131. // OK - try it
  132. //
  133. $oCurrentPerson = MetaModel::GetObject('Person', UserRights::GetContactId(), false);
  134. if ($oCurrentPerson)
  135. {
  136. $this->Set('item_org_id', $oCurrentPerson->Get($sAttCode));
  137. }
  138. }
  139. }
  140. }
  141. }
  142. }
  143. }
  144. /**
  145. * When posting a form, finalize the creation of the inline images
  146. * related to the specified object
  147. *
  148. * @param DBObject $oObject
  149. */
  150. public static function FinalizeInlineImages(DBObject $oObject)
  151. {
  152. $iTransactionId = utils::ReadParam('transaction_id', null);
  153. if (!is_null($iTransactionId))
  154. {
  155. // Attach new (temporary) inline images
  156. $sTempId = utils::GetUploadTempId($iTransactionId);
  157. // The object is being created from a form, check if there are pending inline images for this object
  158. $sOQL = 'SELECT InlineImage WHERE temp_id = :temp_id';
  159. $oSearch = DBObjectSearch::FromOQL($sOQL);
  160. $oSet = new DBObjectSet($oSearch, array(), array('temp_id' => $sTempId));
  161. while($oInlineImage = $oSet->Fetch())
  162. {
  163. $oInlineImage->SetItem($oObject);
  164. $oInlineImage->Set('temp_id', '');
  165. $oInlineImage->DBUpdate();
  166. }
  167. }
  168. }
  169. /**
  170. * Cleanup the pending images if the form is not submitted
  171. * @param string $sTempId
  172. */
  173. public static function OnFormCancel($sTempId)
  174. {
  175. // Delete all "pending" InlineImages for this form
  176. $sOQL = 'SELECT InlineImage WHERE temp_id = :temp_id';
  177. $oSearch = DBObjectSearch::FromOQL($sOQL);
  178. $oSet = new DBObjectSet($oSearch, array(), array('temp_id' => $sTempId));
  179. while($oInlineImage = $oSet->Fetch())
  180. {
  181. $oInlineImage->DBDelete();
  182. }
  183. }
  184. /**
  185. * Parses the supplied HTML fragment to rebuild the attribute src="" for images
  186. * that refer to an InlineImage (detected via the attribute data-img-id="") so that
  187. * the URL is consistent with the current URL of the application.
  188. * @param string $sHtml The HTML fragment to process
  189. * @return string The modified HTML
  190. */
  191. public static function FixUrls($sHtml)
  192. {
  193. $aNeedles = array();
  194. $aReplacements = array();
  195. // Find img tags with an attribute data-img-id
  196. if (preg_match_all('/<img ([^>]*)data-img-id="([0-9]+)"([^>]*)>/i', $sHtml, $aMatches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE))
  197. {
  198. $sUrl = utils::GetAbsoluteUrlAppRoot().INLINEIMAGE_DOWNLOAD_URL;
  199. foreach($aMatches as $aImgInfo)
  200. {
  201. $sImgTag = $aImgInfo[0][0];
  202. $sSecret = '';
  203. if (preg_match('/data-img-secret="([0-9a-f]+)"/', $sImgTag, $aSecretMatches))
  204. {
  205. $sSecret = '&s='.$aSecretMatches[1];
  206. }
  207. $sAttId = $aImgInfo[2][0];
  208. $sNewImgTag = preg_replace('/src="[^"]+"/', 'src="'.htmlentities($sUrl.$sAttId.$sSecret, ENT_QUOTES, 'UTF-8').'"', $sImgTag); // preserve other attributes, must convert & to &amp; to be idempotent with CKEditor
  209. $aNeedles[] = $sImgTag;
  210. $aReplacements[] = $sNewImgTag;
  211. }
  212. $sHtml = str_replace($aNeedles, $aReplacements, $sHtml);
  213. }
  214. return $sHtml;
  215. }
  216. /**
  217. * Get the javascript fragment - to be added to "on document ready" - to adjust (on the fly) the width on Inline Images
  218. */
  219. public static function FixImagesWidth()
  220. {
  221. $iMaxWidth = (int)MetaModel::GetConfig()->Get('inline_image_max_display_width', 0);
  222. $sJS = '';
  223. if ($iMaxWidth != 0)
  224. {
  225. $sJS =
  226. <<<EOF
  227. $('img[data-img-id]').each(function() {
  228. if ($(this).width() > $iMaxWidth)
  229. {
  230. $(this).css({'max-width': '{$iMaxWidth}px', width: '', height: '', 'max-height': ''});
  231. }
  232. $(this).addClass('inline-image').attr('href', $(this).attr('src'));
  233. }).magnificPopup({type: 'image', closeOnContentClick: true });
  234. EOF
  235. ;
  236. }
  237. return $sJS;
  238. }
  239. /**
  240. * Check if an the given mimeType is an image that can be processed by the system
  241. * @param string $sMimeType
  242. * @return boolean
  243. */
  244. public static function IsImage($sMimeType)
  245. {
  246. if (!function_exists('gd_info')) return false; // no image processing capability on this system
  247. $bRet = false;
  248. $aInfo = gd_info(); // What are the capabilities
  249. switch($sMimeType)
  250. {
  251. case 'image/gif':
  252. return $aInfo['GIF Read Support'];
  253. break;
  254. case 'image/jpeg':
  255. return $aInfo['JPEG Support'];
  256. break;
  257. case 'image/png':
  258. return $aInfo['PNG Support'];
  259. break;
  260. }
  261. return $bRet;
  262. }
  263. /**
  264. * Resize an image so that it fits the maximum width/height defined in the config file
  265. * @param ormDocument $oImage The original image stored as an array (content / mimetype / filename)
  266. * @return ormDocument The resampled image (or the original one if it already fit)
  267. */
  268. public static function ResizeImageToFit(ormDocument $oImage, &$aDimensions = null)
  269. {
  270. $img = false;
  271. switch($oImage->GetMimeType())
  272. {
  273. case 'image/gif':
  274. case 'image/jpeg':
  275. case 'image/png':
  276. $img = @imagecreatefromstring($oImage->GetData());
  277. break;
  278. default:
  279. // Unsupported image type, return the image as-is
  280. $aDimensions = null;
  281. return $oImage;
  282. }
  283. if ($img === false)
  284. {
  285. $aDimensions = null;
  286. return $oImage;
  287. }
  288. else
  289. {
  290. // Let's scale the image, preserving the transparency for GIFs and PNGs
  291. $iWidth = imagesx($img);
  292. $iHeight = imagesy($img);
  293. $aDimensions = array('width' => $iWidth, 'height' => $iHeight);
  294. $iMaxImageSize = (int)MetaModel::GetConfig()->Get('inline_image_max_storage_width', 0);
  295. if (($iMaxImageSize > 0) && ($iWidth <= $iMaxImageSize) && ($iHeight <= $iMaxImageSize))
  296. {
  297. // No need to resize
  298. return $oImage;
  299. }
  300. $fScale = min($iMaxImageSize / $iWidth, $iMaxImageSize / $iHeight);
  301. $iNewWidth = $iWidth * $fScale;
  302. $iNewHeight = $iHeight * $fScale;
  303. $aDimensions['width'] = $iNewWidth;
  304. $aDimensions['height'] = $iNewHeight;
  305. $new = imagecreatetruecolor($iNewWidth, $iNewHeight);
  306. // Preserve transparency
  307. if(($oImage->GetMimeType() == "image/gif") || ($oImage->GetMimeType() == "image/png"))
  308. {
  309. imagecolortransparent($new, imagecolorallocatealpha($new, 0, 0, 0, 127));
  310. imagealphablending($new, false);
  311. imagesavealpha($new, true);
  312. }
  313. imagecopyresampled($new, $img, 0, 0, 0, 0, $iNewWidth, $iNewHeight, $iWidth, $iHeight);
  314. ob_start();
  315. switch ($oImage->GetMimeType())
  316. {
  317. case 'image/gif':
  318. imagegif($new); // send image to output buffer
  319. break;
  320. case 'image/jpeg':
  321. imagejpeg($new, null, 80); // null = send image to output buffer, 80 = good quality
  322. break;
  323. case 'image/png':
  324. imagepng($new, null, 5); // null = send image to output buffer, 5 = medium compression
  325. break;
  326. }
  327. $oNewImage = new ormDocument(ob_get_contents(), $oImage->GetMimeType(), $oImage->GetFileName());
  328. @ob_end_clean();
  329. imagedestroy($img);
  330. imagedestroy($new);
  331. return $oNewImage;
  332. }
  333. }
  334. /**
  335. * Get the (localized) textual representation of the max upload size
  336. * @return string
  337. */
  338. public static function GetMaxUpload()
  339. {
  340. $iMaxUpload = ini_get('upload_max_filesize');
  341. if (!$iMaxUpload)
  342. {
  343. $sRet = Dict::S('Attachments:UploadNotAllowedOnThisSystem');
  344. }
  345. else
  346. {
  347. $iMaxUpload = utils::ConvertToBytes($iMaxUpload);
  348. if ($iMaxUpload > 1024*1024*1024)
  349. {
  350. $sRet = Dict::Format('Attachment:Max_Go', sprintf('%0.2f', $iMaxUpload/(1024*1024*1024)));
  351. }
  352. else if ($iMaxUpload > 1024*1024)
  353. {
  354. $sRet = Dict::Format('Attachment:Max_Mo', sprintf('%0.2f', $iMaxUpload/(1024*1024)));
  355. }
  356. else
  357. {
  358. $sRet = Dict::Format('Attachment:Max_Ko', sprintf('%0.2f', $iMaxUpload/(1024)));
  359. }
  360. }
  361. return $sRet;
  362. }
  363. /**
  364. * Get the fragment of javascript needed to complete the initialization of
  365. * CKEditor when creating/modifying an object
  366. *
  367. * @param DBObject $oObject The object being edited
  368. * @param string $sTempId The concatenation of session_id().'_'.$iTransactionId.
  369. * @return string The JS fragment to insert in "on document ready"
  370. */
  371. public static function EnableCKEditorImageUpload(DBObject $oObject, $sTempId)
  372. {
  373. $sObjClass = get_class($oObject);
  374. $iObjKey = $oObject->GetKey();
  375. $sAbsoluteUrlAppRoot = utils::GetAbsoluteUrlAppRoot();
  376. $sToggleFullScreen = htmlentities(Dict::S('UI:ToggleFullScreen'), ENT_QUOTES, 'UTF-8');
  377. $sAppRootUrl = utils::GetAbsoluteUrlAppRoot();
  378. return
  379. <<<EOF
  380. // Hook the file upload of all CKEditor instances
  381. $('.htmlEditor').each(function() {
  382. var oEditor = $(this).ckeditorGet();
  383. oEditor.config.extraPlugins = 'font,uploadimage';
  384. oEditor.config.uploadUrl = '$sAbsoluteUrlAppRoot'+'pages/ajax.render.php';
  385. oEditor.config.filebrowserBrowseUrl = '$sAbsoluteUrlAppRoot'+'pages/ajax.render.php?operation=cke_browse&temp_id=$sTempId&obj_class=$sObjClass&obj_key=$iObjKey';
  386. oEditor.on( 'fileUploadResponse', function( evt ) {
  387. var fileLoader = evt.data.fileLoader;
  388. var xhr = fileLoader.xhr;
  389. var data = evt.data;
  390. try {
  391. var response = JSON.parse( xhr.responseText );
  392. // Error message does not need to mean that upload finished unsuccessfully.
  393. // It could mean that ex. file name was changes during upload due to naming collision.
  394. if ( response.error && response.error.message ) {
  395. data.message = response.error.message;
  396. }
  397. // But !uploaded means error.
  398. if ( !response.uploaded ) {
  399. evt.cancel();
  400. } else {
  401. data.fileName = response.fileName;
  402. data.url = response.url;
  403. // Do not call the default listener.
  404. evt.stop();
  405. }
  406. } catch ( err ) {
  407. // Response parsing error.
  408. data.message = fileLoader.lang.filetools.responseError;
  409. window.console && window.console.log( xhr.responseText );
  410. evt.cancel();
  411. }
  412. } );
  413. oEditor.on( 'fileUploadRequest', function( evt ) {
  414. evt.data.fileLoader.uploadUrl += '?operation=cke_img_upload&temp_id=$sTempId&obj_class=$sObjClass';
  415. }, null, null, 4 ); // Listener with priority 4 will be executed before priority 5.
  416. oEditor.on( 'instanceReady', function() {
  417. if(!CKEDITOR.env.iOS && $('#'+oEditor.id+'_toolbox .editor_magnifier').length == 0)
  418. {
  419. $('#'+oEditor.id+'_toolbox').append('<span class="editor_magnifier" title="$sToggleFullScreen" style="display:block;width:12px;height:11px;border:1px #A6A6A6 solid;cursor:pointer; background-image:url(\\'$sAppRootUrl/images/full-screen.png\\')">&nbsp;</span>');
  420. $('#'+oEditor.id+'_toolbox .editor_magnifier').on('click', function() {
  421. oEditor.execCommand('maximize');
  422. if ($(this).closest('.cke_maximized').length != 0)
  423. {
  424. $('#'+oEditor.id+'_toolbar_collapser').trigger('click');
  425. }
  426. });
  427. }
  428. if (oEditor.widgets.registered.uploadimage)
  429. {
  430. oEditor.widgets.registered.uploadimage.onUploaded = function( upload ) {
  431. var oData = JSON.parse(upload.xhr.responseText);
  432. this.replaceWith( '<img src="' + upload.url + '" ' +
  433. 'width="' + oData.width + '" ' +
  434. 'height="' + oData.height + '">' );
  435. }
  436. }
  437. });
  438. });
  439. EOF
  440. ;
  441. }
  442. }
  443. /**
  444. * Garbage collector for cleaning "old" temporary InlineImages (and Attachments).
  445. * This background process runs every hour and deletes all temporary InlineImages and Attachments
  446. * whic are are older than one hour.
  447. */
  448. class InlineImageGC implements iBackgroundProcess
  449. {
  450. public function GetPeriodicity()
  451. {
  452. return 3600; // Runs every 3600 seconds
  453. }
  454. public function Process($iTimeLimit)
  455. {
  456. $sDateLimit = date(AttributeDateTime::GetSQLFormat(), time()); // Every temporary InlineImage/Attachment expired will be deleted
  457. $iProcessed = 0;
  458. $sOQL = "SELECT InlineImage WHERE (item_id = 0) AND (expire < '$sDateLimit')";
  459. while (time() < $iTimeLimit)
  460. {
  461. // Next one ?
  462. $oSet = new CMDBObjectSet(DBObjectSearch::FromOQL($sOQL), array('expire' => true) /* order by*/, array(), null, 1 /* limit count */);
  463. $oSet->OptimizeColumnLoad(array());
  464. $oResult = $oSet->Fetch();
  465. if (is_null($oResult))
  466. {
  467. // Nothing to be done
  468. break;
  469. }
  470. $iProcessed++;
  471. $oResult->DBDelete();
  472. }
  473. $iProcessed2 = 0;
  474. if (class_exists('Attachment'))
  475. {
  476. $sOQL = "SELECT Attachment WHERE (item_id = 0) AND (expire < '$sDateLimit')";
  477. while (time() < $iTimeLimit)
  478. {
  479. // Next one ?
  480. $oSet = new CMDBObjectSet(DBObjectSearch::FromOQL($sOQL), array('expire' => true) /* order by*/, array(), null, 1 /* limit count */);
  481. $oSet->OptimizeColumnLoad(array());
  482. $oResult = $oSet->Fetch();
  483. if (is_null($oResult))
  484. {
  485. // Nothing to be done
  486. break;
  487. }
  488. $iProcessed2++;
  489. $oResult->DBDelete();
  490. }
  491. }
  492. return "Cleaned $iProcessed old temporary InlineImage(s) and $iProcessed2 old temporary Attachment(s).";
  493. }
  494. }