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
19define('INLINEIMAGE_DOWNLOAD_URL', 'pages/ajax.document.php?operation=download_inlineimage&id=');
20
21/**
22 * Persistent classes (internal): store images referenced inside HTML formatted text fields
23 *
24 * @copyright   Copyright (C) 2016 Combodo SARL
25 * @license     http://opensource.org/licenses/AGPL-3.0
26 */
27
28class InlineImage extends DBObject
29{
30	/** @var string attribute to be added to IMG tags to contain ID */
31	const DOM_ATTR_ID = 'data-img-id';
32	/** @var string attribute to be added to IMG tags to contain secret */
33	const DOM_ATTR_SECRET = 'data-img-secret';
34
35	public static function Init()
36	{
37		$aParams = array
38		(
39			'category' => 'addon',
40			'key_type' => 'autoincrement',
41			'name_attcode' => array('item_class', 'temp_id'),
42			'state_attcode' => '',
43			'reconc_keys' => array(''),
44			'db_table' => 'inline_image',
45			'db_key_field' => 'id',
46			'db_finalclass_field' => '',
47			'indexes' => array(
48				array('temp_id'),
49				array('item_class', 'item_id'),
50				array('item_org_id'),
51			),
52		);
53		MetaModel::Init_Params($aParams);
54		MetaModel::Init_InheritAttributes();
55		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)));
56		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)));
57		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)));
58		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)));
59		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)));
60		MetaModel::Init_AddAttribute(new AttributeBlob("contents", array("is_null_allowed"=>false, "depends_on"=>array(), "always_load_in_tables"=>false)));
61		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)));
62
63
64		MetaModel::Init_SetZListItems('details', array('temp_id', 'item_class', 'item_id', 'item_org_id'));
65		MetaModel::Init_SetZListItems('standard_search', array('temp_id', 'item_class', 'item_id'));
66		MetaModel::Init_SetZListItems('list', array('temp_id', 'item_class', 'item_id' ));
67	}
68
69
70	/**
71	 * Maps the given context parameter name to the appropriate filter/search code for this class
72	 * @param string $sContextParam Name of the context parameter, e.g. 'org_id'
73	 * @return string Filter code, e.g. 'customer_id'
74	 */
75	public static function MapContextParam($sContextParam)
76	{
77		if ($sContextParam == 'org_id')
78		{
79			return 'item_org_id';
80		}
81		else
82		{
83			return null;
84		}
85	}
86
87	/**
88	 * Set/Update all of the '_item' fields
89	 * @param DBObject $oItem Container item
90	 * @return void
91	 */
92	public function SetItem(DBObject $oItem, $bUpdateOnChange = false)
93	{
94		$sClass = get_class($oItem);
95		$iItemId = $oItem->GetKey();
96
97 		$this->Set('item_class', $sClass);
98 		$this->Set('item_id', $iItemId);
99
100		$aCallSpec = array($sClass, 'MapContextParam');
101		if (is_callable($aCallSpec))
102		{
103			$sAttCode = call_user_func($aCallSpec, 'org_id'); // Returns null when there is no mapping for this parameter
104			if (MetaModel::IsValidAttCode($sClass, $sAttCode))
105			{
106				$iOrgId = $oItem->Get($sAttCode);
107				if ($iOrgId > 0)
108				{
109					if ($iOrgId != $this->Get('item_org_id'))
110					{
111						$this->Set('item_org_id', $iOrgId);
112						if ($bUpdateOnChange)
113						{
114							$this->DBUpdate();
115						}
116					}
117				}
118			}
119		}
120	}
121
122	/**
123	 * Give a default value for item_org_id (if relevant...)
124	 * @return void
125	 */
126	public function SetDefaultOrgId()
127	{
128		// First check that the organization CAN be fetched from the target class
129		//
130		$sClass = $this->Get('item_class');
131		$aCallSpec = array($sClass, 'MapContextParam');
132		if (is_callable($aCallSpec))
133		{
134			$sAttCode = call_user_func($aCallSpec, 'org_id'); // Returns null when there is no mapping for this parameter
135			if (MetaModel::IsValidAttCode($sClass, $sAttCode))
136			{
137				// Second: check that the organization CAN be fetched from the current user
138				//
139				if (MetaModel::IsValidClass('Person'))
140				{
141					$aCallSpec = array($sClass, 'MapContextParam');
142					if (is_callable($aCallSpec))
143					{
144						$sAttCode = call_user_func($aCallSpec, 'org_id'); // Returns null when there is no mapping for this parameter
145						if (MetaModel::IsValidAttCode($sClass, $sAttCode))
146						{
147							// OK - try it
148							//
149							$oCurrentPerson = MetaModel::GetObject('Person', UserRights::GetContactId(), false);
150							if ($oCurrentPerson)
151							{
152						 		$this->Set('item_org_id', $oCurrentPerson->Get($sAttCode));
153						 	}
154						}
155					}
156				}
157			}
158		}
159	}
160
161	/**
162	 * When posting a form, finalize the creation of the inline images
163	 * related to the specified object
164	 *
165	 * @param DBObject $oObject
166	 */
167	public static function FinalizeInlineImages(DBObject $oObject)
168	{
169		$iTransactionId = utils::ReadParam('transaction_id', null, false, 'transaction_id');
170		if (!is_null($iTransactionId))
171		{
172			// Attach new (temporary) inline images
173
174			$sTempId = utils::GetUploadTempId($iTransactionId);
175			// The object is being created from a form, check if there are pending inline images for this object
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->SetItem($oObject);
182				$oInlineImage->Set('temp_id', '');
183				$oInlineImage->DBUpdate();
184			}
185		}
186// For tracing issues with Inline Images... but beware not all updates are interactive, so this trace happens when creating objects non-interactively (REST, Synchro...)
187// 		else
188//		{
189//			IssueLog::Error('InlineImage: Error during FinalizeInlineImages(), no transaction ID for object '.get_class($oObject).'#'.$oObject->GetKey().'.');
190//
191//			IssueLog::Error('|- Call stack:');
192//			$oException = new Exception();
193//			$sStackTrace = $oException->getTraceAsString();
194//			IssueLog::Error($sStackTrace);
195//
196//			IssueLog::Error('|- POST vars:');
197//			IssueLog::Error(print_r($_POST, true));
198//		}
199	}
200
201	/**
202	 * Cleanup the pending images if the form is not submitted
203	 * @param string $sTempId
204	 */
205	public static function OnFormCancel($sTempId)
206	{
207		// Delete all "pending" InlineImages for this form
208		$sOQL = 'SELECT InlineImage WHERE temp_id = :temp_id';
209		$oSearch = DBObjectSearch::FromOQL($sOQL);
210		$oSet = new DBObjectSet($oSearch, array(), array('temp_id' => $sTempId));
211		while($oInlineImage = $oSet->Fetch())
212		{
213			$oInlineImage->DBDelete();
214		}
215	}
216
217	/**
218	 * Parses the supplied HTML fragment to rebuild the attribute src="" for images
219	 * that refer to an InlineImage (detected via the attribute data-img-id="") so that
220	 * the URL is consistent with the current URL of the application.
221	 * @param string $sHtml The HTML fragment to process
222	 * @return string The modified HTML
223	 */
224	public static function FixUrls($sHtml)
225	{
226		$aNeedles = array();
227		$aReplacements = array();
228		// Find img tags with an attribute data-img-id
229		if (preg_match_all('/<img ([^>]*)'.self::DOM_ATTR_ID.'="([0-9]+)"([^>]*)>/i',
230			$sHtml, $aMatches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE))
231		{
232			$sUrl = utils::GetAbsoluteUrlAppRoot().INLINEIMAGE_DOWNLOAD_URL;
233			foreach($aMatches as $aImgInfo)
234			{
235				$sImgTag = $aImgInfo[0][0];
236				$sSecret = '';
237				if (preg_match('/data-img-secret="([0-9a-f]+)"/', $sImgTag, $aSecretMatches))
238				{
239					$sSecret = '&s='.$aSecretMatches[1];
240				}
241				$sAttId = $aImgInfo[2][0];
242
243				$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
244				$aNeedles[] = $sImgTag;
245				$aReplacements[] = $sNewImgTag;
246			}
247			$sHtml = str_replace($aNeedles, $aReplacements, $sHtml);
248		}
249		return $sHtml;
250	}
251
252	/**
253	 * Add an extra attribute data-img-id for images which are based on an actual InlineImage
254	 * so that we can later reconstruct the full "src" URL when needed
255	 *
256	 * @param \DOMElement $oElement
257	 */
258	public static function ProcessImageTag(DOMElement $oElement)
259	{
260		$sSrc = $oElement->getAttribute('src');
261		$sDownloadUrl = str_replace(array('.', '?'), array('\.', '\?'), INLINEIMAGE_DOWNLOAD_URL); // Escape . and ?
262		$sUrlPattern = '|'.$sDownloadUrl.'([0-9]+)&s=([0-9a-f]+)|';
263		$bIsInlineImage = preg_match($sUrlPattern, $sSrc, $aMatches);
264		if (!$bIsInlineImage)
265		{
266			return;
267		}
268		$iInlineImageId = $aMatches[1];
269		$sInlineIMageSecret = $aMatches[2];
270
271		$sAppRoot = utils::GetAbsoluteUrlAppRoot();
272		$sAppRootPattern = '/^'.preg_quote($sAppRoot, '/').'/';
273		$bIsSameItop = preg_match($sAppRootPattern, $sSrc);
274		if (!$bIsSameItop)
275		{
276			// @see N°1921
277			// image from another iTop should be treated as external images
278			$oElement->removeAttribute(self::DOM_ATTR_ID);
279			$oElement->removeAttribute(self::DOM_ATTR_SECRET);
280
281			return;
282		}
283
284		$oElement->setAttribute(self::DOM_ATTR_ID, $iInlineImageId);
285		$oElement->setAttribute(self::DOM_ATTR_SECRET, $sInlineIMageSecret);
286	}
287
288	/**
289	 * Get the javascript fragment  - to be added to "on document ready" - to adjust (on the fly) the width on Inline Images
290	 */
291	public static function FixImagesWidth()
292	{
293		$iMaxWidth = (int)MetaModel::GetConfig()->Get('inline_image_max_display_width', 0);
294		$sJS = '';
295		if ($iMaxWidth != 0)
296		{
297			$sJS =
298<<<EOF
299$('img[data-img-id]').each(function() {
300	if ($(this).width() > $iMaxWidth)
301	{
302		$(this).css({'max-width': '{$iMaxWidth}px', width: '', height: '', 'max-height': ''});
303	}
304	$(this).addClass('inline-image').attr('href', $(this).attr('src'));
305}).magnificPopup({type: 'image', closeOnContentClick: true });
306EOF
307			;
308		}
309
310		return $sJS;
311	}
312
313	/**
314	 * Check if an the given mimeType is an image that can be processed by the system
315	 *
316	 * @param string $sMimeType
317	 *
318	 * @return boolean always false if php-gd not installed
319	 *                 otherwise true if file is one of those type : image/gif, image/jpeg, image/png
320	 * @uses php-gd extension
321	 */
322	public static function IsImage($sMimeType)
323	{
324		if (!function_exists('gd_info')) return false; // no image processing capability on this system
325
326		$bRet = false;
327		$aInfo = gd_info(); // What are the capabilities
328		switch($sMimeType)
329		{
330			case 'image/gif':
331				return $aInfo['GIF Read Support'];
332				break;
333
334			case 'image/jpeg':
335				return $aInfo['JPEG Support'];
336				break;
337
338			case 'image/png':
339				return $aInfo['PNG Support'];
340				break;
341
342		}
343		return $bRet;
344	}
345
346	/**
347	 * Resize an image so that it fits the maximum width/height defined in the config file
348	 * @param ormDocument $oImage The original image stored as an array (content / mimetype / filename)
349	 * @return ormDocument The resampled image (or the original one if it already fit)
350	 */
351	public static function ResizeImageToFit(ormDocument $oImage, &$aDimensions = null)
352	{
353		$img = false;
354		switch($oImage->GetMimeType())
355		{
356			case 'image/gif':
357			case 'image/jpeg':
358			case 'image/png':
359				$img = @imagecreatefromstring($oImage->GetData());
360				break;
361
362			default:
363				// Unsupported image type, return the image as-is
364				$aDimensions = null;
365				return $oImage;
366		}
367		if ($img === false)
368		{
369			$aDimensions = null;
370			return $oImage;
371		}
372		else
373		{
374			// Let's scale the image, preserving the transparency for GIFs and PNGs
375			$iWidth = imagesx($img);
376			$iHeight = imagesy($img);
377			$aDimensions = array('width' => $iWidth, 'height' => $iHeight);
378			$iMaxImageSize = (int)MetaModel::GetConfig()->Get('inline_image_max_storage_width', 0);
379
380			if (($iMaxImageSize > 0) && ($iWidth <= $iMaxImageSize) && ($iHeight <= $iMaxImageSize))
381			{
382				// No need to resize
383				return $oImage;
384			}
385
386			$fScale = min($iMaxImageSize / $iWidth, $iMaxImageSize / $iHeight);
387
388			$iNewWidth = $iWidth * $fScale;
389			$iNewHeight = $iHeight * $fScale;
390
391			$aDimensions['width'] = $iNewWidth;
392			$aDimensions['height'] = $iNewHeight;
393
394			$new = imagecreatetruecolor($iNewWidth, $iNewHeight);
395
396			// Preserve transparency
397			if(($oImage->GetMimeType() == "image/gif") || ($oImage->GetMimeType() == "image/png"))
398			{
399				imagecolortransparent($new, imagecolorallocatealpha($new, 0, 0, 0, 127));
400				imagealphablending($new, false);
401				imagesavealpha($new, true);
402			}
403
404			imagecopyresampled($new, $img, 0, 0, 0, 0, $iNewWidth, $iNewHeight, $iWidth, $iHeight);
405
406			ob_start();
407			switch ($oImage->GetMimeType())
408			{
409				case 'image/gif':
410					imagegif($new); // send image to output buffer
411					break;
412
413				case 'image/jpeg':
414					imagejpeg($new, null, 80); // null = send image to output buffer, 80 = good quality
415					break;
416
417				case 'image/png':
418					imagepng($new, null, 5); // null = send image to output buffer, 5 = medium compression
419					break;
420			}
421			$oNewImage = new ormDocument(ob_get_contents(), $oImage->GetMimeType(), $oImage->GetFileName());
422			@ob_end_clean();
423
424			imagedestroy($img);
425			imagedestroy($new);
426
427			return $oNewImage;
428		}
429
430	}
431
432	/**
433	 * Get the (localized) textual representation of the max upload size
434	 * @return string
435	 */
436	public static function GetMaxUpload()
437	{
438		$iMaxUpload = ini_get('upload_max_filesize');
439		if (!$iMaxUpload)
440		{
441			$sRet = Dict::S('Attachments:UploadNotAllowedOnThisSystem');
442		}
443		else
444		{
445			$iMaxUpload = utils::ConvertToBytes($iMaxUpload);
446			if ($iMaxUpload > 1024*1024*1024)
447			{
448				$sRet = Dict::Format('Attachment:Max_Go', sprintf('%0.2f', $iMaxUpload/(1024*1024*1024)));
449			}
450			else if ($iMaxUpload > 1024*1024)
451			{
452				$sRet = Dict::Format('Attachment:Max_Mo', sprintf('%0.2f', $iMaxUpload/(1024*1024)));
453			}
454			else
455			{
456				$sRet = Dict::Format('Attachment:Max_Ko', sprintf('%0.2f', $iMaxUpload/(1024)));
457			}
458		}
459		return $sRet;
460	}
461
462	/**
463	 * Get the fragment of javascript needed to complete the initialization of
464	 * CKEditor when creating/modifying an object
465	 *
466	 * @param \DBObject $oObject The object being edited
467	 * @param string $sTempId Generated through utils::GetUploadTempId($iTransactionId)
468	 *
469	 * @return string The JS fragment to insert in "on document ready"
470	 * @throws \Exception
471	 */
472	public static function EnableCKEditorImageUpload(DBObject $oObject, $sTempId)
473	{
474		$sObjClass = get_class($oObject);
475		$iObjKey = $oObject->GetKey();
476
477		$sAbsoluteUrlAppRoot = utils::GetAbsoluteUrlAppRoot();
478		$sToggleFullScreen = htmlentities(Dict::S('UI:ToggleFullScreen'), ENT_QUOTES, 'UTF-8');
479		$sAppRootUrl = utils::GetAbsoluteUrlAppRoot();
480
481		return
482<<<EOF
483		// Hook the file upload of all CKEditor instances
484		$('.htmlEditor').each(function() {
485			var oEditor = $(this).ckeditorGet();
486			oEditor.config.extraPlugins = 'font,uploadimage';
487			oEditor.config.uploadUrl = '$sAbsoluteUrlAppRoot'+'pages/ajax.render.php';
488			oEditor.config.filebrowserBrowseUrl = '$sAbsoluteUrlAppRoot'+'pages/ajax.render.php?operation=cke_browse&temp_id=$sTempId&obj_class=$sObjClass&obj_key=$iObjKey';
489			oEditor.on( 'fileUploadResponse', function( evt ) {
490				var fileLoader = evt.data.fileLoader;
491				var xhr = fileLoader.xhr;
492				var data = evt.data;
493				try {
494			        var response = JSON.parse( xhr.responseText );
495
496			        // Error message does not need to mean that upload finished unsuccessfully.
497			        // It could mean that ex. file name was changes during upload due to naming collision.
498			        if ( response.error && response.error.message ) {
499			            data.message = response.error.message;
500			        }
501
502			        // But !uploaded means error.
503			        if ( !response.uploaded ) {
504			            evt.cancel();
505			        } else {
506			            data.fileName = response.fileName;
507			           	data.url = response.url;
508
509			            // Do not call the default listener.
510			            evt.stop();
511			        }
512			    } catch ( err ) {
513			        // Response parsing error.
514			        data.message = fileLoader.lang.filetools.responseError;
515			        window.console && window.console.log( xhr.responseText );
516
517			        evt.cancel();
518			    }
519			} );
520
521			oEditor.on( 'fileUploadRequest', function( evt ) {
522				evt.data.fileLoader.uploadUrl += '?operation=cke_img_upload&temp_id=$sTempId&obj_class=$sObjClass';
523			}, null, null, 4 ); // Listener with priority 4 will be executed before priority 5.
524
525			oEditor.on( 'instanceReady', function() {
526				if(!CKEDITOR.env.iOS && $('#'+oEditor.id+'_toolbox .editor_magnifier').length == 0)
527				{
528					$('#'+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>');
529					$('#'+oEditor.id+'_toolbox .editor_magnifier').on('click', function() {
530							oEditor.execCommand('maximize');
531							if ($(this).closest('.cke_maximized').length != 0)
532							{
533								$('#'+oEditor.id+'_toolbar_collapser').trigger('click');
534							}
535					});
536				}
537				if (oEditor.widgets.registered.uploadimage)
538				{
539					oEditor.widgets.registered.uploadimage.onUploaded = function( upload ) {
540					var oData = JSON.parse(upload.xhr.responseText);
541				    	this.replaceWith( '<img src="' + upload.url + '" ' +
542				    		'width="' + oData.width + '" ' +
543							'height="' + oData.height + '">' );
544				    }
545				}
546			});
547		});
548EOF
549		;
550	}
551}
552
553
554/**
555 * Garbage collector for cleaning "old" temporary InlineImages (and Attachments).
556 * This background process runs every hour and deletes all temporary InlineImages and Attachments
557 * whic are are older than one hour.
558 */
559class InlineImageGC implements iBackgroundProcess
560{
561    public function GetPeriodicity()
562    {
563        return 1; // Runs every 8 hours
564    }
565
566	/**
567	 * @param int $iTimeLimit
568	 *
569	 * @return string
570	 * @throws \CoreException
571	 * @throws \CoreUnexpectedValue
572	 * @throws \DeleteException
573	 * @throws \MySQLException
574	 * @throws \OQLException
575	 */
576	public function Process($iTimeLimit)
577	{
578		$sDateLimit = date(AttributeDateTime::GetSQLFormat(), time()); // Every temporary InlineImage/Attachment expired will be deleted
579
580		$aResults = array();
581		$aClasses = array('InlineImage', 'Attachment');
582		foreach($aClasses as $sClass)
583		{
584			$iProcessed = 0;
585			if(class_exists($sClass))
586			{
587				$iProcessed = $this->DeleteExpiredDocuments($sClass, $iTimeLimit, $sDateLimit);
588			}
589			$aResults[] = "$iProcessed old temporary $sClass(s)";
590		}
591
592		return "Cleaned ".implode(' and ', $aResults).".";
593	}
594
595	/**
596	 * @param string $sClass
597	 * @param int $iTimeLimit
598	 * @param string $sDateLimit
599	 *
600	 * @return int
601	 * @throws \CoreException
602	 * @throws \CoreUnexpectedValue
603	 * @throws \DeleteException
604	 * @throws \MySQLException
605	 * @throws \OQLException
606	 */
607	protected function DeleteExpiredDocuments($sClass, $iTimeLimit, $sDateLimit)
608	{
609		$iProcessed = 0;
610		$sOQL = "SELECT $sClass WHERE (item_id = 0) AND (expire < '$sDateLimit')";
611		// Next one ?
612		$oSet = new CMDBObjectSet(DBObjectSearch::FromOQL($sOQL), array('expire' => true) /* order by*/, array(), null,
613			1 /* limit count */);
614		$oSet->OptimizeColumnLoad(array());
615		while ((time() < $iTimeLimit) && ($oResult = $oSet->Fetch()))
616		{
617			/** @var \ormDocument $oDocument */
618			$oDocument = $oResult->Get('contents');
619			IssueLog::Info($sClass.' GC: Removed temp. file '.$oDocument->GetFileName().' on "'.$oResult->Get('item_class').'" #'.$oResult->Get('item_id').' as it has expired.');
620			$oResult->DBDelete();
621			$iProcessed++;
622		}
623
624		return $iProcessed;
625}
626}