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 & 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\\')"> </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}