Explorar el Código

Upgrade script from 14.0.0-RC3 to 14.0.0-RC4

Alex Trofimov hace 1 año
padre
commit
cf6a384c32
Se han modificado 100 ficheros con 34678 adiciones y 0 borrados
  1. 517 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolAI.php
  2. 350 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolAIAssistant.php
  3. 150 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolAIModelGpt35.php
  4. 487 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolAIModelGpt40.php
  5. 2628 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolCmts.php
  6. 1356 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolDb.php
  7. 226 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolInstallerUtils.php
  8. 343 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolLiveUpdates.php
  9. 545 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolMenu.php
  10. 1221 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolMetatags.php
  11. 596 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolScore.php
  12. 2767 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/utils.inc.php
  13. 12 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/version.inc.php
  14. 874 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/modules/base/general/classes/BxBaseModGeneralFormsEntryHelper.php
  15. 4544 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/modules/base/general/classes/BxBaseModGeneralModule.php
  16. 2549 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/modules/base/groups/classes/BxBaseModGroupsModule.php
  17. 286 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/modules/base/profile/classes/BxBaseModProfileFormEntry.php
  18. 282 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/modules/base/profile/classes/BxBaseModProfileFormsEntryHelper.php
  19. 2556 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/modules/base/profile/classes/BxBaseModProfileModule.php
  20. 242 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/AbstractTest.php
  21. 217 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/BigFiles.php
  22. 25 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/BucketLocation.php
  23. 52 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/BucketsList.php
  24. 67 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/HeadObject.php
  25. 308 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/ListFiles.php
  26. 111 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/ListThousandsOfFiles.php
  27. 103 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/Multipart.php
  28. 78 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/SignedURLs.php
  29. 43 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/SingleSmallFile.php
  30. 109 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/SmallFiles.php
  31. 28 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/SmallFilesNoDelete.php
  32. 31 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/SmallFilesOnlyUpload.php
  33. 57 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/SmallInlineFiles.php
  34. 28 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/SmallInlineFilesNoDelete.php
  35. 30 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/SmallInlineFilesOnlyUpload.php
  36. 131 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/SmallInlineXMLFiles.php
  37. 74 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/StorageClasses.php
  38. 303 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/config.dist.php
  39. 456 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/minitest.php
  40. 31 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Acl.php
  41. 447 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Configuration.php
  42. 983 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Connector.php
  43. 19 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/CannotDeleteFile.php
  44. 19 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/CannotGetBucket.php
  45. 19 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/CannotGetFile.php
  46. 19 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/CannotListBuckets.php
  47. 27 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/CannotOpenFileForRead.php
  48. 27 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/CannotOpenFileForWrite.php
  49. 19 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/CannotPutFile.php
  50. 23 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/ConfigurationError.php
  51. 32 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/InvalidAccessKey.php
  52. 33 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/InvalidBody.php
  53. 32 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/InvalidEndpoint.php
  54. 30 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/InvalidFilePointer.php
  55. 32 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/InvalidRegion.php
  56. 32 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/InvalidSecretKey.php
  57. 32 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/InvalidSignatureMethod.php
  58. 22 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/PropertyNotFound.php
  59. 766 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Input.php
  60. 823 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Request.php
  61. 351 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Response.php
  62. 139 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Response/Error.php
  63. 80 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Signature.php
  64. 218 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Signature/V2.php
  65. 534 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Signature/V4.php
  66. 98 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/StorageClass.php
  67. 35 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/aliasing.php
  68. 7 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/autoload.php
  69. 139 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/ClientAssertionType/HttpBasic.php
  70. 481 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/Controller/AuthorizeController.php
  71. 156 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/Controller/ResourceController.php
  72. 333 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/Controller/TokenController.php
  73. 247 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/GrantType/JwtBearer.php
  74. 62 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/OpenID/Controller/UserInfoController.php
  75. 178 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/OpenID/ResponseType/IdToken.php
  76. 252 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/Request.php
  77. 218 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/ResponseType/AccessToken.php
  78. 159 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/ResponseType/JwtAccessToken.php
  79. 1020 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/Server.php
  80. 87 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/Storage/JwtAccessToken.php
  81. 82 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Environment.php
  82. 79 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Exceptions/APIError.php
  83. 162 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Guzzle.php
  84. 121 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/ListResult.php
  85. 171 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Model.php
  86. 117 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/Addon.php
  87. 52 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/Address.php
  88. 73 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/AttachedItem.php
  89. 43 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/BusinessEntity.php
  90. 85 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/Card.php
  91. 57 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/Comment.php
  92. 31 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/Configuration.php
  93. 131 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/Coupon.php
  94. 55 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/CouponCode.php
  95. 80 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/CouponSet.php
  96. 166 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/CreditNote.php
  97. 20 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/CreditNoteAllocation.php
  98. 20 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/CreditNoteDiscount.php
  99. 20 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/CreditNoteEstimateDiscount.php
  100. 70 0
      upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/Currency.php

+ 517 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolAI.php

@@ -0,0 +1,517 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+define('BX_DOL_AI_ASSISTANT', 'assistant');
+define('BX_DOL_AI_AUTOMATOR_EVENT', 'event');
+define('BX_DOL_AI_AUTOMATOR_SCHEDULER', 'scheduler');
+define('BX_DOL_AI_AUTOMATOR_WEBHOOK', 'webhook');
+
+define('BX_DOL_AI_AUTOMATOR_STATUS_AUTO', 'auto');
+define('BX_DOL_AI_AUTOMATOR_STATUS_MANUAL', 'manual');
+define('BX_DOL_AI_AUTOMATOR_STATUS_READY', 'ready');
+
+class BxDolAI extends BxDolFactory implements iBxDolSingleton
+{
+    protected $_oDb;
+    protected $_iProfileId;
+    
+    protected $_aExcludeAlertUnits;
+
+    protected $_sCmtsAutomators;
+    protected $_sCmtsAssistantsChats;
+
+    protected $_bWriteLog;
+
+    protected function __construct()
+    {
+        if (isset($GLOBALS['bxDolClasses'][get_class($this)]))
+            trigger_error ('Multiple instances are not allowed for the class: ' . get_class($this), E_USER_ERROR);
+
+        parent::__construct();
+
+        $this->_oDb = new BxDolAIQuery();
+
+        $this->_iProfileId = (int)getParam('sys_profile_bot'); 
+
+        $this->_aExcludeAlertUnits = [
+            'system', 'module_template_method_call'
+        ];
+
+        $this->_sCmtsAutomators = 'sys_agents_automators';
+        $this->_sCmtsAssistantsChats = 'sys_agents_assistants_chats';
+
+        $this->_bWriteLog = true;
+    }
+
+    /**
+     * Prevent cloning the instance
+     */
+    public function __clone()
+    {
+        if (isset($GLOBALS['bxDolClasses'][get_class($this)]))
+            trigger_error('Clone is not allowed for the class: ' . get_class($this), E_USER_ERROR);
+    }
+
+    /**
+     * Get singleton instance of the class
+     */
+    public static function getInstance()
+    {
+        if(!isset($GLOBALS['bxDolClasses'][__CLASS__]))
+            $GLOBALS['bxDolClasses'][__CLASS__] = new BxDolAI();
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    public static function callHelper($mixedHelper, $sMessage)
+    {
+        $oAI = BxDolAI::getInstance();
+        if (is_numeric($mixedHelper))
+            $aHelper = $oAI->getHelperById($mixedHelper);
+        else
+             $aHelper = $oAI->getHelperByName($mixedHelper);
+        $oAIModel = $oAI->getModelObject($aHelper['model_id']);
+        return $oAIModel->getResponseText($aHelper['prompt'], $sMessage);
+    }
+
+    public static function pruning()
+    {
+        BxDolAIAssistant::pruning();
+    }
+
+    public static function getDefaultApiKey()
+    {
+        return getParam('sys_agents_api_key');
+    }
+
+    public static function getDefaultModel()
+    {
+        return (int)getParam('sys_agents_model');
+    }
+
+    public static function getAssistantForStudio()
+    {
+        return ($iId = (int)getParam('sys_agents_studio_assistant')) != 0 ? $iId : 0;
+    }
+
+    public static function getAssistantForLiveSearch()
+    {
+        return ($iId = (int)getParam('sys_agents_live_search_assistant')) != 0 ? $iId : 0;
+    }
+
+    public static function getAssistantForAskBlock()
+    {
+        return ($iId = (int)getParam('sys_agents_ask_block_assistant')) != 0 ? $iId : 0;
+    }
+
+    public function getProfileId()
+    {
+        return $this->_iProfileId;
+    }
+
+    public function getModels($aParams = [])
+    {
+        $aParamsDb = ['sample' => 'all_pairs'];
+        if(isset($aParams['active']))
+            $aParamsDb['active'] = $aParams['active'] === true ? 1 : 0;
+        if(isset($aParams['hidden']))
+            $aParamsDb['hidden'] = $aParams['hidden'] === true ? 1 : 0;
+
+        return $aModel = $this->_oDb->getModelsBy($aParamsDb);
+    }
+
+    public function getModel($iId)
+    {
+        $aModel = $this->_oDb->getModelsBy(['sample' => 'id', 'id' => $iId]);
+        if(!empty($aModel['params']))
+            $aModel['params'] = json_decode($aModel['params'], true);
+
+        return $aModel;
+    }
+
+    public function getModelObject($iId)
+    {
+        if(!$iId)
+            $iId = $this->getDefaultModel();
+        if(!$iId)
+            return false;
+
+        return BxDolAIModel::getObjectInstance($iId);
+    }
+    
+    public function getProviderObject($iId)
+    {
+        if(!$iId)
+            return false;
+
+        return BxDolAIProvider::getObjectInstance($iId);
+    }   
+
+    public function getAssistants($aParams = [])
+    {
+        $aParamsDb = ['sample' => 'all_pairs'];
+        if(isset($aParams['active']))
+            $aParamsDb['active'] = $aParams['active'] === true ? 1 : 0;
+        if(isset($aParams['hidden']))
+            $aParamsDb['hidden'] = $aParams['hidden'] === true ? 1 : 0;
+
+        return $aModel = $this->_oDb->getAssistantsBy($aParamsDb);
+    }
+
+    public function getAssistantById($iId)
+    {
+        return $this->_oDb->getAssistantsBy(['sample' => 'id', 'id' => $iId]);
+    }
+
+    public function getAssistantByName($sName)
+    {
+        return $this->_oDb->getAssistantsBy(['sample' => 'name', 'name' => $sName]);
+    }
+
+    public function getAssistantChatById($iId)
+    {
+        return $this->_oDb->getChatsBy(['sample' => 'id', 'id' => $iId]);
+    }
+
+    public function getAssistantChatsTransient($iLifetime = 0)
+    {
+        return $this->_oDb->getChatsBy(['sample' => 'type', 'type' => BX_DOL_AI_ASST_TYPE_TRANSIENT, 'lifetime' => $iLifetime]);
+    }
+
+    public function updateAssistantChatById($iId, $aSet)
+    {
+        return $this->_oDb->updateChats($aSet, ['id' => $iId]);
+    }
+
+    public function getAssistantChatCmts()
+    {
+        return $this->_sCmtsAssistantsChats;
+    }
+
+    public function getAssistantChatCmtsObject($iId, $oTemplate = false)
+    {
+        $oCmts = BxDolCmts::getObjectInstance($this->_sCmtsAssistantsChats, (int)$iId, true, $oTemplate);
+        if(!$oCmts || !$oCmts->isEnabled())
+            return false;
+
+        return $oCmts;
+    }
+
+    public function getHelperById($iId)
+    {
+        return $this->_oDb->getHelpersBy(['sample' => 'id', 'id' => $iId]);
+    }
+    
+    public function getHelperByName($sName)
+    {
+        return $this->_oDb->getHelpersBy(['sample' => 'name', 'name' => $sName]);
+    }
+
+    public function getAutomator($iId, $bFullInfo = false)
+    {
+        $aAutomator = $this->_oDb->getAutomatorsBy(['sample' => 'id' . ($bFullInfo ? '_full' : ''), 'id' => $iId]);
+        if(!empty($aAutomator['params']))
+            $aAutomator['params'] = json_decode($aAutomator['params'], true);
+        if($bFullInfo && !empty($aAutomator['model_params']))
+            $aAutomator['model_params'] = json_decode($aAutomator['model_params'], true);
+
+        return $aAutomator;
+    }
+
+    public function getAutomatorInstruction($sType, $mixedParams = false)
+    {
+        $mixedResult = '';
+
+        switch($sType) {
+            case 'profile':
+                $mixedResult = "\n ProfileId for system actions = " . $mixedParams;
+                break;
+
+            case 'providers':
+                $aProviders = $this->_oDb->getProvidersBy(['sample' => 'ids', 'ids' => $mixedParams]);
+                if(!empty($aProviders) && is_array($aProviders)) {
+                    $mixedResult = "\n Proividers list = [";
+                    foreach($aProviders as $aProvider)
+                        $mixedResult .= "\n {'ProviderName' => '" . $aProvider['name'] . "',  'ProviderType' => '" . $aProvider['type_name'] . "'}";
+                    $mixedResult .= "\n ]";
+                }
+                break;
+
+            case 'helpers':
+                $aHelpers = $this->_oDb->getHelpersBy(['sample' => 'ids', 'ids' => $mixedParams]);
+                if(!empty($aHelpers) && is_array($aHelpers)) {
+                    $mixedResult = "\n Helpers list = [";
+                    foreach($aHelpers as $aHelper)
+                        $mixedResult .= "\n {'" . $aHelper['name'] . "', 'HelperDescription' => '" . $aHelper['description'] . "'}";
+                    $mixedResult .= "\n ]";
+                }
+                break;
+
+            case 'assistants':
+                $aAssistants = $this->_oDb->getAssistantsBy(['sample' => 'ids', 'ids' => $mixedParams]);
+                if(!empty($aAssistants) && is_array($aAssistants)) {
+                    $mixedResult = "\n Assistants list = [";
+                    foreach($aAssistants as $aAssistant)
+                        $mixedResult .= "\n {'" . $aAssistant['name'] . "', 'AssistantDescription' => '" . $aAssistant['description'] . "'}";
+                    $mixedResult .= "\n ]";
+                }
+                break;
+        }
+
+        return $mixedResult;
+    }
+
+    public function getAutomatorCmts()
+    {
+        return $this->_sCmtsAutomators;
+    }
+
+    public function getAutomatorCmtsObject($iId, $oTemplate = false)
+    {
+        $oCmts = BxDolCmts::getObjectInstance($this->_sCmtsAutomators, (int)$iId, true, $oTemplate);
+        if(!$oCmts || !$oCmts->isEnabled())
+            return false;
+
+        return $oCmts;
+    }
+
+    public function getAutomatorsEvent($sUnit, $sAction)
+    {
+        if(in_array($sUnit, $this->_aExcludeAlertUnits))
+            return [];
+
+        return $this->_oDb->getAutomatorsBy([
+            'sample' => 'events', 
+            'alert_unit' => $sUnit,
+            'alert_action' => $sAction,
+            'active' => true
+        ]);
+    }
+
+    public function getAutomatorsScheduler()
+    {
+        $aAutomators = $this->_oDb->getAutomatorsBy(['sample' => 'schedulers', 'active' => true]);
+        foreach($aAutomators as &$aAutomator)
+            if(!empty($aAutomator['params']))
+                $aAutomator['params'] = json_decode($aAutomator['params'], true);
+
+        return $aAutomators;
+    }
+
+    public function getAutomatorsWebhook($iProviderId)
+    {
+        $aAutomators = $this->_oDb->getAutomatorsBy(['sample' => 'webhooks', 'provider_id' => $iProviderId, 'active' => true]);
+        foreach($aAutomators as &$aAutomator)
+            if(!empty($aAutomator['params']))
+                $aAutomator['params'] = json_decode($aAutomator['params'], true);
+
+        return $aAutomators;
+    }
+
+    public function callAutomator($sType, $aParams = [])
+    {
+        $sMethod = '_callAutomator' . bx_gen_method_name($sType);
+        if(!method_exists($this, $sMethod))
+            return false;
+
+        return $this->$sMethod($aParams);
+    }
+
+    protected function _callAutomatorEvent($aParams = [])
+    {
+        if(!isset($aParams['automator'], $aParams['alert']) || !is_a($aParams['alert'], 'BxDolAlerts'))
+            return false;
+        
+        $oAlert = &$aParams['alert'];
+
+        $this->evalCode($aParams['automator'], ['alert' => $oAlert]);
+    }
+
+    protected function _callAutomatorScheduler($aParams = [])
+    {
+        if(!isset($aParams['automator']))
+            return false;
+        
+        $this->evalCode($aParams['automator']);
+    }
+
+    protected function _callAutomatorWebhook($aParams = [])
+    {
+        if(!isset($aParams['automator']))
+            return false;
+
+        $this->evalCode($aParams['automator']);
+    }
+
+    public function evalCode($aAutomator, $aParams = [])
+    {
+        try {
+            $this->_evalCode($aAutomator, $aParams);
+        }
+        catch (Exception $oException) {
+            $this->log($oException->getFile() . ':' . $oException->getLine() . ' ' . $oException->getMessage());
+        }
+        catch (Error $oError) {
+            $this->log($oError->getFile() . ':' . $oError->getLine() . ' ' . $oError->getMessage());
+        }
+    }
+
+    public function emulCode($aAutomator, $aParams = [])
+    {
+        ob_start();
+
+        try {
+            $this->_evalCode($aAutomator, $aParams);
+        }
+        catch (Exception $oException) {
+            return $oException->getMessage();
+        }
+        catch (Error $oError) {
+            return $oError->getMessage();
+        }
+        finally {
+            $sOutput = ob_get_clean();
+
+            if(!empty($sOutput))
+                return $sOutput;
+        }
+    }
+
+    public function log($mixedContents, $sSection = '')
+    {
+        if(!$this->_bWriteLog)
+            return;
+
+        if(is_array($mixedContents))
+            $mixedContents = var_export($mixedContents, true);	
+        else if(is_object($mixedContents))
+            $mixedContents = json_encode($mixedContents);
+
+        if(empty($sSection))
+            $sSection = "Core";
+
+        bx_log('sys_agents', ":\n[" . $sSection . "] " . $mixedContents);
+    }
+
+    protected function _evalCode($aAutomator, $aParams = [])
+    {
+        $sCode = '';
+        switch($aAutomator['type']) {
+            case BX_DOL_AI_AUTOMATOR_EVENT:
+                $sCode = $aAutomator['code']. '; onAlert($aParams["alert"]->iObject , $aParams["alert"]->iSender , $aParams["alert"]->aExtras);';
+                break;
+
+            case BX_DOL_AI_AUTOMATOR_SCHEDULER:
+                $sCode = $aAutomator['code'] . '; onCron();';
+                break;
+
+            case BX_DOL_AI_AUTOMATOR_WEBHOOK:
+                $sCode = $aAutomator['code'] . '; onHook();';
+                break;
+        }
+
+        eval($sCode);
+    }
+}
+
+class BxDolAIMessage
+{
+    /**
+     * @var string - message Type with following values: hb, ai 
+     */
+    protected $_sType;
+    
+    /**
+     * @var mixed - an array of message parts (text, image_url) or a string.
+     */
+    protected $_mixedContent;
+
+    public function __construct($sType)
+    {
+        $this->_sType = $sType;
+    }
+
+    public function isAi()
+    {
+        return $this->_sType == 'ai';
+    }
+
+    public function getContent()
+    {
+        return $this->_mixedContent;
+    }
+}
+
+class BxDolAIMessageString extends BxDolAIMessage
+{
+    public function __construct($sType, $sContent)
+    {
+        parent::__construct($sType);
+
+        $this->_mixedContent = is_string($sContent) ? $sContent : '';
+    }
+}
+
+class BxDolAIMessageArray extends BxDolAIMessage
+{
+    public function __construct($sType, $aContent = '')
+    {
+        parent::__construct($sType);
+
+        $this->_mixedContent = is_array($aContent) ? $aContent : [];
+    }
+
+    public function addText($sText)
+    {
+        $this->_mixedContent[] = [
+            'type' => 'text',
+            'text' => $sText
+        ];
+    }
+
+    public function addImageUrl($sUrl, $sDetail = 'high')
+    {
+        $this->_mixedContent[] = [
+            'type' => 'image_url',
+            'image_url' => [
+                'url' => $sUrl,
+                'detail' => $sDetail
+            ]
+        ];
+    }
+}
+
+class BxDolAIMessages
+{
+    /**
+     * @var array - an array of items (messages)
+     */
+    protected $_aItems;
+
+    public function __construct($aItems = [])
+    {
+        $this->_aItems = !empty($aItems) && is_array($aItems) ? $aItems : [];
+    }
+
+    public function add($sType, $mixedMessage)
+    {
+        $sClass = 'BxDolAIMessage' . (is_string($mixedMessage) ? 'String' : 'Array');
+        $this->_aItems[] = new $sClass($sType, $mixedMessage);
+    }
+
+    public function getAll()
+    {
+        return $this->_aItems;
+    }
+
+    public function getLast()
+    {
+        return end($this->_aItems);
+    }
+}

+ 350 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolAIAssistant.php

@@ -0,0 +1,350 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+define('BX_DOL_AI_ASST_TYPE_PERMANENT', 1);
+define('BX_DOL_AI_ASST_TYPE_TRANSIENT', 10);
+
+class BxDolAIAssistant extends BxDol
+{
+    protected static $_sFileText = 'sys_asst_text.json';
+    protected static $_sFileFaq = 'sys_asst_faq.json';
+
+    protected $_oDb;   
+
+    protected $_iId;
+    protected $_aData;    
+
+    public function __construct($aAssistant)
+    {
+        parent::__construct();
+
+        if(empty($aAssistant) || !is_array($aAssistant))
+            $this->_log("Unexpected value provided for the credentials");
+
+        $this->_oDb = new BxDolAIQuery();
+
+        $this->_iId = (int)$aAssistant['id'];
+        $this->_aData = $aAssistant;
+    }
+
+    /**
+     * Get assistant object instance by ID
+     * @param $iId assistant ID
+     * @return object instance or false on error
+     */
+    public static function getObjectInstance($iId)
+    {
+        $sPrefix = 'BxDolAIAssistant!';
+
+        if(isset($GLOBALS['bxDolClasses'][$sPrefix . $iId]))
+            return $GLOBALS['bxDolClasses'][$sPrefix . $iId];
+
+        $aAssistant = BxDolAIQuery::getAssistantObject($iId);
+        if(!$aAssistant)
+            return false;
+
+        $o = new BxDolAIAssistant($aAssistant);
+        return ($GLOBALS['bxDolClasses'][$sPrefix . $iId] = $o);
+    }
+
+    public static function getName($sName)
+    {
+        return uriGenerate($sName, 'sys_agents_assistants', 'name', ['lowercase' => false]);
+    }
+
+    public static function getChatName($sName)
+    {
+        return uriGenerate($sName, 'sys_agents_assistants_chats', 'name', ['lowercase' => false]);
+    }
+
+    public static function pruning()
+    {
+        $oAi = BxDolAI::getInstance();
+
+        if(getParam('sys_agents_asst_chats_trans_del') == 'on') {
+            $aChats = $oAi->getAssistantChatsTransient(3600);
+            foreach($aChats as $aChat)
+                if(!empty($aChat['assistant_id']) && ($oAssistant = self::getObjectInstance($aChat['assistant_id'])) !== false)
+                    $oAssistant->deleteChat($aChat);
+        }
+    }
+
+    public function getModelObject()
+    {
+        return BxDolAI::getInstance()->getModelObject($this->_aData['model_id']);
+    }
+
+    public function getChatCmtsObject($iChatId)
+    {
+        return BxDolAI::getInstance()->getAssistantChatCmtsObject($iChatId);
+    }
+
+    public function getAskButton($sText)
+    {
+        $sTitle = _t('_sys_agents_assistants_txt_ask');
+        if(!empty($sText))
+            $sTitle .= ': ' . strmaxtextlen($sText, 16);
+
+        return BxDolTemplate::getInstance()->parseButton($sTitle, [
+            'class' => 'bx-btn sys-agents-ask',
+            'onclick' => "javascrip:bx_agents_action(this, 'asst', 'ask', {id: " . $this->_iId . ", text: '" . $sText . "'})"
+        ]);
+    }
+    
+    public function getAskChat($sName = '', $sText = '', $oTemplate = false)
+    {
+        if(!$oTemplate)
+            $oTemplate = BxDolTemplate::getInstance();
+
+        $bName = !empty($sName);
+        $bText = !empty($sText);
+
+        $iChatId = 0;
+        if($bName) {
+            $aChat = $this->_oDb->getChatsBy(['sample' => 'name', 'name' => $sName]);
+            if(!empty($aChat) && is_array($aChat))
+                $iChatId = (int)$aChat['id'];
+        }
+
+        if(empty($iChatId)) {
+            if(!$bName)
+                $sName = self::getChatName($bText ? strmaxtextlen($sText, 8) : genRndPwd());
+
+            $iChatId = $this->_oDb->insertChat([
+                'name' => $sName,
+                'type' => BX_DOL_AI_ASST_TYPE_TRANSIENT,
+                'assistant_id' => $this->_iId, 
+                'added' => time(),
+            ]);
+        }
+
+        $sResult = '';
+        if($iChatId !== false && ($oCmts = BxDolAI::getInstance()->getAssistantChatCmtsObject($iChatId, $oTemplate)) !== false) {
+            $oCmts->setAllowDelete(false);
+
+            if(!empty($sText))
+                $oCmts->add([
+                    'cmt_author_id' => bx_get_logged_profile_id(),
+                    'cmt_parent_id' => 0,
+                    'cmt_text' => $sText
+                ]);
+
+            $sResult = $oCmts->getCommentsBlock([]);
+        }
+
+        return $sResult;
+    }
+    
+    public function getAskBlock($aParams = [])
+    {
+        return $this->getAskChat();
+    }
+
+    public function deleteChat($mixedChat)
+    {
+        $aChat = is_array($mixedChat) ? $mixedChat : $this->_oDb->getChatsBy(['sample' => 'id', 'id' => (int)$mixedChat]);
+        if(empty($aChat) || !is_array($aChat))
+            return false;      
+
+        if(($oCmts = $this->getChatCmtsObject($aChat['id'])) !== false)
+            $oCmts->onObjectDelete();
+
+        if(!empty($aChat['ai_file_id'])) {
+            $oAIModel = $this->getModelObject();
+            $oAIModel->callVectorStoresFilesDelete($this->_aData['ai_vs_id'], $aChat['ai_file_id']);
+            $oAIModel->callFilesDelete($aChat['ai_file_id']);
+        }
+
+        return $this->_oDb->deleteChats(['id' => $aChat['id']]);
+    }
+
+    public function processActionAddKnowledge()
+    {
+        $sAction = 'add_knowledge';
+        $oTemplate = BxDolTemplate::getInstance();
+
+        $aForm = $this->_getForm($sAction, ['id' => $this->_iId]);
+        $oForm = new BxTemplFormView($aForm);
+        $oForm->initChecker();
+
+        if($oForm->isSubmittedAndValid()) {
+            $oAIModel = $this->getModelObject();
+
+            $sType = $oForm->getCleanValue('type');
+
+            $sFile = self::${'_sFile' . ucfirst($sType)};
+            $aFile = $this->_oDb->getFilesBy(['sample' => 'assistant_id', 'assistant_id' => $this->_iId, 'name' => $sFile]);
+
+            $aContent = [];
+            if(!empty($aFile) && is_array($aFile) && !empty($aFile['ai_file_id'])) {
+                $sContent = $oAIModel->callFilesRetrieveContent($aFile['ai_file_id']);
+                $aContent = json_decode($sContent, true);
+
+                if(!$oAIModel->callVectorStoresFilesDelete($this->_aData['ai_vs_id'], $aFile['ai_file_id']))
+                    return echoJson([]);
+
+                if(!$oAIModel->callFilesDelete($aFile['ai_file_id']))
+                    return echoJson([]);
+
+                $this->_oDb->deleteFiles(['id' => $aFile['id']]);
+            }
+
+            switch($sType) {
+                case 'text':
+                    $aContent[] = bx_process_input($oForm->getCleanValue('text'));
+                    break;
+
+                case 'faq':
+                    $aContent[] = [
+                        'question' => bx_process_input($oForm->getCleanValue('faq_q')),
+                        'answer' => bx_process_input($oForm->getCleanValue('faq_a'))
+                    ];
+                    break;
+            }
+
+            $aFileResponse = $oAIModel->callFiles(['content' => json_encode($aContent), 'name' => $sFile, 'mime' => 'application/json']);
+            if(!$aFileResponse)
+                return echoJson([]);
+
+            if(!$oAIModel->callVectorStoresFiles($this->_aData['ai_vs_id'], ['file_id' => $aFileResponse['id']]))
+                return echoJson([]);
+
+            $this->_oDb->insertFile([
+                'name' => $sFile, 
+                'assistant_id' => $this->_iId, 
+                'added' => time(), 
+                'ai_file_id' => $aFileResponse['id'], 
+                'ai_file_size' => $aFileResponse['bytes']
+            ]);
+
+            return echoJson(['msg' => _t('_sys_agents_assistants_msg_knowledge_added')]);
+        }
+
+        $sFormId = $oForm->getId();
+        $sContent = BxTemplFunctions::getInstance()->popupBox($sFormId . '_popup', _t('_sys_agents_assistants_popup_add_knowledge'), $oTemplate->parseHtmlByName('agents_form.html', [
+            'form_id' => $sFormId,
+            'form' => $oForm->getCode(true),
+        ]));
+
+        return echoJson(['popup' => ['html' => $sContent, 'options' => ['closeOnOuterClick' => false]]]);
+    }
+
+    public function processActionAsk()
+    {
+        $sAction = 'ask';
+        $oTemplate = BxDolTemplate::getInstance();
+
+        $sText = bx_get('text');
+        if($sText !== false)
+            $sText = bx_process_input($sText);
+
+        $iChatId = $this->_oDb->insertChat([
+            'name' => self::getChatName(strmaxtextlen($sText, 8)),
+            'type' => BX_DOL_AI_ASST_TYPE_TRANSIENT,
+            'assistant_id' => $this->_iId, 
+            'added' => time(),
+        ]);
+
+        $sPopupContent = '';
+        if($iChatId !== false && ($oCmts = BxDolAI::getInstance()->getAssistantChatCmtsObject($iChatId, $oTemplate)) !== false) {
+            $oCmts->setAllowDelete(false);
+            $oCmts->add([
+                'cmt_author_id' => bx_get_logged_profile_id(),
+                'cmt_parent_id' => 0,
+                'cmt_text' => $sText
+            ]);
+
+            $sPopupContent = $oCmts->getCommentsBlock([], ['dynamic_mode' => true]);
+        }
+
+        $sPopupId = 'bx_agents_assistants_ask';
+        $sPopupContent = BxTemplFunctions::getInstance()->popupBox($sPopupId, _t('_sys_agents_assistants_popup_ask'), $oTemplate->parseHtmlByName('agents_popup.html', [
+            'content' => $sPopupContent
+        ]));
+
+        return echoJson(['popup' => ['html' => $sPopupContent, 'options' => ['closeOnOuterClick' => false]]]);
+    }
+
+    protected function _getForm($sAction, $aAssistant = [])
+    {
+        $aForm = [
+            'form_attrs' => [
+                'id' => 'bx_agents_assistants_' . $sAction,
+                'action' => BX_DOL_URL_ROOT . bx_append_url_params('agents.php', ['t' => 'asst', 'a' => $sAction]),
+                'method' => 'post',
+            ],
+            'params' => [
+                'db' => [
+                    'submit_name' => 'do_submit',
+                ],
+            ],
+            'inputs' => [],
+        ];
+
+        switch($sAction) {
+            case 'add_knowledge';
+                $aForm['inputs'] = [
+                    'id' => [
+                        'type' => 'hidden',
+                        'name' => 'id',
+                        'value' => $aAssistant['id'],
+                    ],
+                    'type' => [
+                        'type' => 'select',
+                        'name' => 'type',
+                        'caption' => _t('_sys_agents_assistants_field_kwg_type'),
+                        'value' => '',
+                        'values' => [
+                            ['key' => 'text', 'value' => _t('_sys_agents_assistants_field_kwg_type_text')],
+                            ['key' => 'faq', 'value' => _t('_sys_agents_assistants_field_kwg_type_faq')]
+                        ],
+                        'attrs' => ['onchange' => "javascript:bx_aa_ak_type_change(this)"],
+                    ],
+                    'text' => [
+                        'type' => 'textarea',
+                        'name' => 'text',
+                        'caption' => _t('_sys_agents_assistants_field_kwg_text'),
+                        'value' => '',
+                        'tr_attrs' => ['df' => '1', 'dt' => 'text']
+                    ],
+                    'faq_q' => [
+                        'type' => 'text',
+                        'name' => 'faq_q',
+                        'caption' => _t('_sys_agents_assistants_field_kwg_faq_q'),
+                        'value' => '',
+                        'tr_attrs' => ['df' => '1', 'dt' => 'faq', 'style' => 'display:none']
+                    ],
+                    'faq_a' => [
+                        'type' => 'textarea',
+                        'name' => 'faq_a',
+                        'caption' => _t('_sys_agents_assistants_field_kwg_faq_a'),
+                        'value' => '',
+                        'tr_attrs' => ['df' => '1', 'dt' => 'faq', 'style' => 'display:none']
+                    ],
+                    'submit' => [
+                        'type' => 'input_set',
+                        0 => [
+                            'type' => 'submit',
+                            'name' => 'do_submit',
+                            'value' => _t('_sys_submit'),
+                        ],
+                        1 => [
+                            'type' => 'reset',
+                            'name' => 'close',
+                            'value' => _t('_sys_close'),
+                            'attrs' => ['class' => 'bx-def-margin-sec-left', 'onclick' => '$(\'.bx-popup-applied:visible\').dolPopupHide();'],
+                        ],
+                    ],
+                ];
+                break;
+        }
+
+        return $aForm;
+    }
+}

+ 150 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolAIModelGpt35.php

@@ -0,0 +1,150 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAIModelGpt35 extends BxDolAIModel
+{
+    public static $NAME = 'gpt-3.5-turbo';
+
+    protected $_sEndpoint;
+    protected $_sPathInst;
+
+    public function __construct($aModel)
+    {
+        $this->_sName = self::$NAME;
+
+        parent::__construct($aModel);
+
+        $this->_sEndpoint = "https://api.openai.com/v1/chat/completions";
+        $this->_sPathInst = BX_DIRECTORY_PATH_ROOT . 'ai/instructions/';
+    }
+
+    public function getResponseText($sPrompt, $sMessage)
+    {
+        $aMessages = [
+            ['role' => 'system', 'content' => $sPrompt],
+            ['role' => 'user', 'content' => $sMessage]
+        ];
+
+        $sResponse = $this->call($aMessages);
+        if($sResponse == 'false')
+            return false;
+        
+        return $sResponse;
+    }
+    
+    public function getResponseInit($sType, $oMessage, $aParams = [])
+    {
+        if(!is_a($oMessage, 'BxDolAIMessage'))
+            return false;
+
+        $aMessages = [
+            ['role' => 'system', 'content' => $this->_getInstructions($sType . '_init')],
+            ['role' => 'user', 'content' => $oMessage->getContent()]
+        ];
+
+        $sResponse = $this->call($aMessages);
+        if($sResponse == 'false')
+            return false;
+
+        $mixedResult = [];
+        switch($sType) {
+            case BX_DOL_AI_AUTOMATOR_EVENT:
+                $aResponse = json_decode($sResponse, true);
+
+                $mixedResult = [
+                    'alert_unit' => $aResponse['alert_unit'],
+                    'alert_action' => $aResponse['alert_action'],
+                    'params' => [
+                        'trigger' => $aResponse['trigger']
+                    ]
+                ];
+                break;
+
+            case BX_DOL_AI_AUTOMATOR_SCHEDULER:
+                $mixedResult = [
+                    'params' => [
+                        'scheduler_time' => $sResponse
+                    ]
+                ];
+                break;
+
+            case BX_DOL_AI_AUTOMATOR_WEBHOOK:
+                break;
+        }
+        
+
+        return $mixedResult;
+    }
+
+    public function getResponse($sType, $mixedMessage, $aParams = [])
+    {
+        $bMessage = is_a($mixedMessage, 'BxDolAIMessageString');
+        $bMessages = is_a($mixedMessage, 'BxDolAIMessages');
+        if(!$bMessage && !$bMessages)
+            return false;
+
+        $aCall = [
+            ['role' => 'system', 'content' => $this->_getInstructions($sType, true)]
+        ];
+
+        if($bMessages && ($aMessages = $mixedMessage->getAll()))
+            foreach($aMessages as $oMessage)
+                $aCall[] = [
+                    'role' => $oMessage->isAi() ? 'assistant' : 'user',
+                    'content' => $oMessage->getContent() . (!$oMessage->isAi() ? ' return only function, without explanation or false if you havn`t enougth data' : '')
+                ];
+        else if($bMessage)
+            $aCall[] = [
+                'role' => $oMessage->isAi() ? 'assistant' : 'user', 
+                'content' => $mixedMessage->getContent()
+            ];
+
+        $sResponse = $this->call($aCall);
+        if($sResponse == 'false')
+            return false;
+
+        return $sResponse;
+    }
+
+    public function call($aMessages, $aParams = [])
+    {
+        $aData = [
+            'model' => $this->_sName,
+            'messages' => $aMessages
+        ];
+
+        if(!empty($this->_aParams['call']) && is_array($this->_aParams['call']))
+            $aData = array_merge($aData, $this->_aParams['call']);
+        if(!empty($aParams) && is_array($aParams))
+            $aData = array_merge($aData, $aParams);
+
+        $sResponse = bx_file_get_contents($this->_sEndpoint, $aData, "post-json", [
+            "Authorization: Bearer " . $this->_sKey, 
+            'Content-Type: application/json', 
+            'OpenAI-Beta: assistants=v1'
+        ]);
+
+        $aResponse = json_decode($sResponse, true);
+        if(isset($aResponse['error'])) {
+            $this->_log($aResponse['error']);
+            return 'false';
+        }
+
+        return trim(str_replace(['```json', '```php', '```'], '', $aResponse['choices'][0]['message']['content']));
+    }
+
+    protected function _getInstructions($sType, $bIncludeCommon = false)
+    {
+        $sResult = file_get_contents($this->_sPathInst . $sType . '.html');
+        if($bIncludeCommon)
+            $sResult .= file_get_contents($this->_sPathInst. 'common.html');
+
+        return $sResult;
+    }
+}

+ 487 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolAIModelGpt40.php

@@ -0,0 +1,487 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAIModelGpt40 extends BxDolAIModel
+{
+    public static $NAME = 'gpt-4o';
+
+    protected $_sEndpoint;
+    protected $_sEndpointRuns;
+    protected $_sEndpointRunsCheck;
+    protected $_sEndpointMessages;
+
+    protected $_sEndpointAssistants;
+    protected $_sEndpointAssistantsDelete;
+
+    protected $_sEndpointFiles;
+    protected $_sEndpointFilesRetrieve;
+    protected $_sEndpointFilesRetrieveContent;
+    protected $_sEndpointFilesDelete;
+
+    protected $_sEndpointVectorStores;
+    protected $_sEndpointVectorStoresDelete;
+
+    protected $_sEndpointVectorStoresFiles;
+    protected $_sEndpointVectorStoresFilesRetrieve;
+    protected $_sEndpointVectorStoresFilesDelete;   
+
+    protected $_sEndpointChat;
+
+    public function __construct($aModel)
+    {
+        $this->_sName = self::$NAME;
+
+        parent::__construct($aModel);
+
+        $this->_sEndpoint = 'https://api.openai.com/v1/threads';
+        $this->_sEndpointRuns = $this->_sEndpoint . '/%s/runs';
+        $this->_sEndpointRunsCheck = $this->_sEndpoint . '/%s/runs/%s';
+        $this->_sEndpointMessages = $this->_sEndpoint . '/%s/messages';
+
+        $this->_sEndpointAssistants = 'https://api.openai.com/v1/assistants';
+        $this->_sEndpointAssistantsDelete = $this->_sEndpointAssistants . '/%s';
+
+        $this->_sEndpointFiles = 'https://api.openai.com/v1/files';
+        $this->_sEndpointFilesRetrieve = $this->_sEndpointFiles . '/%s';
+        $this->_sEndpointFilesRetrieveContent = $this->_sEndpointFiles . '/%s/content';
+        $this->_sEndpointFilesDelete = $this->_sEndpointFilesRetrieve;
+
+        $this->_sEndpointVectorStores = 'https://api.openai.com/v1/vector_stores';
+        $this->_sEndpointVectorStoresDelete = $this->_sEndpointVectorStores . '/%s';
+
+        $this->_sEndpointVectorStoresFiles = $this->_sEndpointVectorStores . '/%s/files';
+        $this->_sEndpointVectorStoresFilesRetrieve = $this->_sEndpointVectorStoresFiles . '/%s';
+        $this->_sEndpointVectorStoresFilesDelete = $this->_sEndpointVectorStoresFiles . '/%s';
+
+        $this->_sEndpointChat = 'https://api.openai.com/v1/chat/completions';
+    }
+    
+    public function getResponseText($sPrompt, $sMessage)
+    {
+        $aMessages = [
+            ['role' => 'system', 'content' => $sPrompt],
+            ['role' => 'user', 'content' => $sMessage]
+        ];
+
+        $sResponse = $this->callChat($aMessages);
+        if($sResponse == 'false')
+            return false;
+        
+        return $sResponse;
+    }
+
+    public function getResponseInit($sType, $oMessage, $aParams = [])
+    {
+        if(!is_a($oMessage, 'BxDolAIMessage'))
+            return false;
+
+        $aResponse = $this->call(['messages' => [['role' => 'user', 'content' => $oMessage->getContent()]]]);
+        if(!isset($aResponse['id'], $aResponse['object']) || $aResponse['object'] != 'thread')
+            return false;
+
+        $sThreadId = $aResponse['id'];
+        $sAssistantId = isset($aParams['assistant_id']) ? $aParams['assistant_id'] : $this->_getAssistantId($sType . '_init');
+
+        if(!$this->callRuns($sThreadId, ['assistant_id' => $sAssistantId]))
+            return false;
+
+        $sResponse = $this->getMessages($sThreadId);
+
+        $mixedResult = [];
+        switch($sType) {
+            case BX_DOL_AI_AUTOMATOR_EVENT:
+                $aResponse = json_decode($sResponse, true);
+
+                $mixedResult = [
+                    'alert_unit' => $aResponse['alert_unit'],
+                    'alert_action' => $aResponse['alert_action'],
+                    'params' => [
+                        'thread_id' => $sThreadId,
+                        'trigger' => $aResponse['trigger']
+                    ]
+                ];
+                break;
+
+            case BX_DOL_AI_AUTOMATOR_SCHEDULER:
+                $mixedResult = [
+                    'params' => [
+                        'thread_id' => $sThreadId,
+                        'scheduler_time' => $sResponse
+                    ]
+                ];
+                break;
+
+            case BX_DOL_AI_AUTOMATOR_WEBHOOK:
+            case BX_DOL_AI_ASSISTANT:
+                $mixedResult = [
+                    'params' => [
+                        'thread_id' => $sThreadId,
+                    ]
+                ];
+                break;
+        }
+
+        return $mixedResult;
+    }
+
+    public function getResponse($sType, $mixedMessage, $aParams = [])
+    {
+        $bMessages = false;
+        if(!(is_a($mixedMessage, 'BxDolAIMessage')) && !($bMessages = is_a($mixedMessage, 'BxDolAIMessages')))
+            return false;
+
+        if(empty($aParams['thread_id']))
+            return false;
+
+        if($bMessages)
+            $mixedMessage = $mixedMessage->getLast();
+
+        $sThreadId = $aParams['thread_id'];
+        if($mixedMessage->isAi() || !$this->callMessages($sThreadId, ['role' => 'user', 'content' => $mixedMessage->getContent()]))
+            return false;
+
+        $sAssistantId = isset($aParams['assistant_id']) ? $aParams['assistant_id'] : $this->_getAssistantId($sType);
+        if(!$this->callRuns($sThreadId, ['assistant_id' => $sAssistantId]))
+            return false;
+
+        return $this->getMessages($sThreadId);
+    }
+
+    public function getAssistant($aParams = [])
+    {
+        $aResponseVs = $this->callVectorStores(['name' => $aParams['name']]);
+        if($aResponseVs === false)
+            return false;
+        
+        $sVectorStoreId = $aResponseVs['id'];
+
+        $aResponseAsst = $this->callAssistants([
+            'model' => $this->_sName, 
+            'name' => $aParams['name'], 
+            'instructions' => $aParams['prompt'], 
+            'tools' => [
+                ['type' => 'file_search']
+            ],
+            'tool_resources' => [
+                'file_search' => [
+                    'vector_store_ids' => [$sVectorStoreId]
+                ]
+            ]
+        ]);
+        if($aResponseAsst === false)
+            return false;
+        
+        $sAssistantId = $aResponseAsst['id'];
+
+        return [
+            'vector_store_id' => $sVectorStoreId,
+            'assistant_id' => $sAssistantId
+        ];
+    }
+
+    public function call($aParams = [])
+    {
+        $aData = [];
+        if(!empty($this->_aParams['call']) && is_array($this->_aParams['call']))
+            $aData = array_merge($aData, $this->_aParams['call']);
+        if(!empty($aParams) && is_array($aParams))
+            $aData = array_merge($aData, $aParams);
+
+        return $this->_call($this->_sEndpoint, $aData);
+        
+    }
+
+    public function callRuns($sThreadId, $aParams = [])
+    {
+        $aData = [];
+        if(!empty($this->_aParams['call_runs']) && is_array($this->_aParams['call_runs']))
+            $aData = array_merge($aData, $this->_aParams['call_runs']);
+        if(!empty($aParams) && is_array($aParams))
+            $aData = array_merge($aData, $aParams);
+
+        $aResponse = $this->_call(sprintf($this->_sEndpointRuns, $sThreadId), $aData);
+        if($aResponse !== false && isset($aResponse['id'])) {
+            $sRunId = $aResponse['id'];
+
+            while($aResponse['status'] != 'completed') {
+                sleep(2);
+
+                $aResponse = $this->_call(sprintf($this->_sEndpointRunsCheck, $sThreadId, $sRunId), [], 'get');
+            }
+        }
+
+        return $aResponse;
+    }
+
+    public function callMessages($sThreadId, $aParams = [])
+    {
+        $aData = [];
+        if(!empty($this->_aParams['call_messages']) && is_array($this->_aParams['call_messages']))
+            $aData = array_merge($aData, $this->_aParams['call_messages']);
+        if(!empty($aParams) && is_array($aParams))
+            $aData = array_merge($aData, $aParams);
+
+        $mixedResponse = $this->_call(sprintf($this->_sEndpointMessages, $sThreadId), $aData);
+        if($mixedResponse !== false)
+            $mixedResponse = $mixedResponse['content'][0]['text']['value'];
+
+        return $mixedResponse;
+    }
+
+    /**
+     * Create a vector store.
+     * 
+     * @param type $aParams - should have 'name'
+     * @return boolean
+     */
+    public function callVectorStores($aParams = [])
+    {
+        $aData = [];
+        if(!empty($this->_aParams['call_vs']) && is_array($this->_aParams['call_vs']))
+            $aData = array_merge($aData, $this->_aParams['call_vs']);
+        if(!empty($aParams) && is_array($aParams))
+            $aData = array_merge($aData, $aParams);
+
+        $mixedResponse = $this->_call($this->_sEndpointVectorStores, $aData);
+        if(empty($mixedResponse) || !is_array($mixedResponse) || $mixedResponse['object'] != 'vector_store')
+            return false;
+
+        return $mixedResponse;
+    }
+    
+    public function callVectorStoresDelete($sVectorStoreId, $aParams = [])
+    {
+        $aData = [];
+        if(!empty($this->_aParams['call_vs_delete']) && is_array($this->_aParams['call_vs_delete']))
+            $aData = array_merge($aData, $this->_aParams['call_vs_delete']);
+        if(!empty($aParams) && is_array($aParams))
+            $aData = array_merge($aData, $aParams);
+
+        $mixedResponse = $this->_call(sprintf($this->_sEndpointVectorStoresDelete, $sVectorStoreId), [], 'DELETE');
+        if(empty($mixedResponse) || !is_array($mixedResponse) || !$mixedResponse['deleted'])
+            return false;
+
+        return $mixedResponse;
+    }
+
+    /**
+     * Create a vector store file by attaching a File to a vector store.
+     * 
+     * @param type $sVectorStoreId
+     * @param type $aParams - should have 'file_id'
+     * @return boolean
+     */
+    public function callVectorStoresFiles($sVectorStoreId, $aParams = [])
+    {
+        $aData = [];
+        if(!empty($this->_aParams['call_vs_files']) && is_array($this->_aParams['call_vs_files']))
+            $aData = array_merge($aData, $this->_aParams['call_vs_files']);
+        if(!empty($aParams) && is_array($aParams))
+            $aData = array_merge($aData, $aParams);
+
+        $mixedResponse = $this->_call(sprintf($this->_sEndpointVectorStoresFiles, $sVectorStoreId), $aData);
+        if(empty($mixedResponse) || !is_array($mixedResponse) || $mixedResponse['object'] != 'vector_store.file')
+            return false;
+
+        return $mixedResponse;
+    }
+    
+    public function callVectorStoresFilesList($sVectorStoreId, $aParams = [])
+    {
+        $aData = [];
+        if(!empty($this->_aParams['call_vs_flist']) && is_array($this->_aParams['call_vs_flist']))
+            $aData = array_merge($aData, $this->_aParams['call_vs_flist']);
+        if(!empty($aParams) && is_array($aParams))
+            $aData = array_merge($aData, $aParams);
+
+        $mixedResponse = $this->_call(sprintf($this->_sEndpointVectorStoresFiles, $sVectorStoreId), $aData, 'get');
+        if(empty($mixedResponse) || !is_array($mixedResponse) || $mixedResponse['object'] != 'list')
+            return false;
+
+        return $mixedResponse['data'];
+    }
+
+    public function callVectorStoresFilesRetrieveFile($sVectorStoreId, $sFileId)
+    {
+        return $this->_call(sprintf($this->_sEndpointVectorStoresFilesRetrieve, $sVectorStoreId, $sFileId), [], 'get');
+    }
+    
+    public function callVectorStoresFilesDelete($sVectorStoreId, $sFileId, $aParams = [])
+    {
+        $aData = [];
+        if(!empty($this->_aParams['call_vs_files_retrieve']) && is_array($this->_aParams['call_vs_files_retrieve']))
+            $aData = array_merge($aData, $this->_aParams['call_vs_files_retrieve']);
+        if(!empty($aParams) && is_array($aParams))
+            $aData = array_merge($aData, $aParams);
+
+        return $this->_call(sprintf($this->_sEndpointVectorStoresFilesDelete, $sVectorStoreId, $sFileId), [], 'DELETE');
+    }
+
+    public function callAssistants($aParams = [])
+    {
+        $aData = [];
+        if(!empty($this->_aParams['call_assts']) && is_array($this->_aParams['call_assts']))
+            $aData = array_merge($aData, $this->_aParams['call_assts']);
+        if(!empty($aParams) && is_array($aParams))
+            $aData = array_merge($aData, $aParams);
+
+        $mixedResponse = $this->_call($this->_sEndpointAssistants, $aData);
+        if(empty($mixedResponse) || !is_array($mixedResponse) || $mixedResponse['object'] != 'assistant')
+            return false;
+
+        return $mixedResponse;
+    }
+    
+    public function callAssistantsDelete($sAsstId, $aParams = [])
+    {
+        $aData = [];
+        if(!empty($this->_aParams['call_assts_delete']) && is_array($this->_aParams['call_assts_delete']))
+            $aData = array_merge($aData, $this->_aParams['call_assts_delete']);
+        if(!empty($aParams) && is_array($aParams))
+            $aData = array_merge($aData, $aParams);
+
+        $mixedResponse = $this->_call(sprintf($this->_sEndpointAssistantsDelete, $sAsstId), $aData, 'DELETE');
+        if(empty($mixedResponse) || !is_array($mixedResponse) || !$mixedResponse['deleted'])
+            return false;
+
+        return $mixedResponse;
+    }
+
+    public function callFiles($aFile, $aParams = [])
+    {
+        $aData = [];
+        if(!empty($this->_aParams['call_files']) && is_array($this->_aParams['call_files']))
+            $aData = array_merge($aData, $this->_aParams['call_files']);
+        if(!empty($aParams) && is_array($aParams))
+            $aData = array_merge($aData, $aParams);
+
+        if(empty($aData['purpose']))
+            $aData['purpose'] = 'assistants';
+
+        $sName = !empty($aFile['name']) ? $aFile['name'] : 'file_' . time() . '.txt';
+        $sMime = !empty($aFile['mime']) ? $aFile['mime'] : 'text/plain';
+        $aData['file'] = new CURLStringFile($aFile['content'], $sName, $sMime);
+
+        $mixedResponse = $this->_callFiles($this->_sEndpointFiles, $aData);
+        if(empty($mixedResponse) || !is_array($mixedResponse) || $mixedResponse['object'] != 'file')
+            return false;
+
+        return $mixedResponse;
+    }
+
+    public function callFilesRetrieve($sFileId)
+    {
+        $mixedResponse = $this->_callFiles(sprintf($this->_sEndpointFilesRetrieve, $sFileId), [], 'get');
+        if(empty($mixedResponse) || !is_array($mixedResponse) || $mixedResponse['object'] != 'file')
+            return false;
+
+        return $mixedResponse;
+    }
+
+    public function callFilesRetrieveContent($sFileId)
+    {
+        return bx_file_get_contents(sprintf($this->_sEndpointFilesRetrieveContent, $sFileId), [], 'get', [
+            "Authorization: Bearer " . $this->_sKey
+        ]);
+    }
+
+    public function callFilesDelete($sFileId, $aParams = [])
+    {
+        $aData = [];
+        if(!empty($this->_aParams['call_files_delete']) && is_array($this->_aParams['call_files_delete']))
+            $aData = array_merge($aData, $this->_aParams['call_files_delete']);
+        if(!empty($aParams) && is_array($aParams))
+            $aData = array_merge($aData, $aParams);
+
+        $mixedResponse = $this->_callFiles(sprintf($this->_sEndpointFilesDelete, $sFileId), $aData, 'DELETE');
+        if(empty($mixedResponse) || !is_array($mixedResponse) || $mixedResponse['object'] != 'file')
+            return false;
+
+        return $mixedResponse;
+    }
+
+    public function callChat($aMessages, $aParams = [])
+    {
+        $aData = [
+            'model' => $this->_sName,
+            'messages' => $aMessages
+        ];
+
+        if(!empty($this->_aParams['call']) && is_array($this->_aParams['call']))
+            $aData = array_merge($aData, $this->_aParams['call']);
+        if(!empty($aParams) && is_array($aParams))
+            $aData = array_merge($aData, $aParams);
+
+        $sResponse = bx_file_get_contents($this->_sEndpointChat, $aData, "post-json", [
+            "Authorization: Bearer " . $this->_sKey, 
+            'Content-Type: application/json', 
+            'OpenAI-Beta: assistants=v1'
+        ]);
+
+        $aResponse = json_decode($sResponse, true);
+        if(isset($aResponse['error'])) {
+            $this->_log($aResponse['error']);
+            return 'false';
+        }
+
+        return trim(str_replace(['```json', '```php', '```'], '', $aResponse['choices'][0]['message']['content']));
+    }
+
+    public function getMessages($sThreadId, $aParams = [])
+    {
+        $aData = [];
+        if(!empty($this->_aParams['get_messages']) && is_array($this->_aParams['get_messages']))
+            $aData = array_merge($aData, $this->_aParams['get_messages']);
+        if(!empty($aParams) && is_array($aParams))
+            $aData = array_merge($aData, $aParams);
+
+        $mixedResponse = $this->_call(sprintf($this->_sEndpointMessages, $sThreadId), $aData, "get");
+        if($mixedResponse !== false)
+            $mixedResponse = trim(str_replace(['```json', '```php', '```'], '', $mixedResponse['data'][0]['content'][0]['text']['value']));
+
+        return $mixedResponse;
+    }
+
+    protected function _call($sEndpoint, $aData, $sMethod = "post-json")
+    {
+        $sResponse = bx_file_get_contents($sEndpoint, $aData, $sMethod, [
+            "Authorization: Bearer " . $this->_sKey, 
+            'Content-Type: application/json', 
+            'OpenAI-Beta: assistants=v2'
+        ]);
+
+        $aResponse = json_decode($sResponse, true);
+        if(isset($aResponse['error'])) {
+            $this->_log($aResponse['error']);
+            return false;
+        }
+
+        return $aResponse;
+    }
+
+    protected function _callFiles($sEndpoint, $aData, $sMethod = "post-raw")
+    {
+        $sResponse = bx_file_get_contents($sEndpoint, $aData, $sMethod, [
+            "Authorization: Bearer " . $this->_sKey
+        ]);
+
+        $aResponse = json_decode($sResponse, true);
+        if(isset($aResponse['error'])) {
+            $this->_log($aResponse['error']);
+            return false;
+        }
+
+        return $aResponse;
+    }
+
+    protected function _getAssistantId($sType)
+    {
+        return isset($this->_aParams['assistants'][$sType]) ? $this->_aParams['assistants'][$sType] : '';
+    }
+}

+ 2628 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolCmts.php

@@ -0,0 +1,2628 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+bx_import('BxDolAcl');
+bx_import('BxDolPrivacy');
+
+define('BX_DOL_CMT_TYPE_COMMENTS', 'comments');
+define('BX_DOL_CMT_TYPE_REVIEWS', 'reviews');
+
+define('BX_CMT_OLD_VOTES', 365*86400); ///< comment votes older than this number of seconds will be deleted automatically
+
+define('BX_CMT_ACTION_POST', 'post');
+define('BX_CMT_ACTION_EDIT', 'edit');
+
+define('BX_CMT_DISPLAY_FLAT', 'flat');
+define('BX_CMT_DISPLAY_THREADED', 'threaded');
+
+define('BX_CMT_BROWSE_HEAD', 'head');
+define('BX_CMT_BROWSE_TAIL', 'tail');
+define('BX_CMT_BROWSE_POPULAR', 'popular');
+define('BX_CMT_BROWSE_CONNECTION', 'connection');
+
+define('BX_CMT_ORDER_BY_DATE', 'date');
+define('BX_CMT_ORDER_BY_POPULAR', 'popular');
+
+define('BX_CMT_FILTER_ALL', 'all');
+define('BX_CMT_FILTER_OTHERS', 'others');
+define('BX_CMT_FILTER_FRIENDS', 'friends');
+define('BX_CMT_FILTER_SUBSCRIPTIONS', 'subscriptions');
+define('BX_CMT_FILTER_PINNED', 'pinned');
+
+define('BX_CMT_ORDER_WAY_ASC', 'asc');
+define('BX_CMT_ORDER_WAY_DESC', 'desc');
+
+define('BX_CMT_PFP_TOP', 'top');
+define('BX_CMT_PFP_BOTTOM', 'bottom');
+
+define('BX_CMT_RATE_VALUE_PLUS', 1);
+define('BX_CMT_RATE_VALUE_MINUS', -1);
+
+define('BX_CMT_USAGE_BLOCK', 'block');
+define('BX_CMT_USAGE_INLINE', 'inline');
+define('BX_CMT_USAGE_DEFAULT', BX_CMT_USAGE_BLOCK);
+
+define('BX_CMT_STATUS_ACTIVE', 'active');
+define('BX_CMT_STATUS_HIDDEN', 'hidden');
+define('BX_CMT_STATUS_PENDING', 'pending');
+
+
+/**
+ * Comments for any content
+ *
+ * Related classes:
+ * - BxDolCmtsQuery - comments database queries
+ * - BxBaseCmts - comments base representation
+ * - BxTemplCmts - custom template representation
+ *
+ * AJAX comments for any content.
+ * Self moderated - users rate all comments, and if comment is
+ * below viewing treshold it is hidden by default.
+ *
+ * To add comments section to your module you need to add a record to 'sys_objects_cmts' table:
+ *
+ * - ID - autoincremented id for internal usage
+ * - ObjectName - your unique module name, with vendor prefix, lowercase and spaces are underscored
+ * - TableCmts - table name where comments are stored
+ * - Html - 0 - convert new lines to <br /> on saving, 1 - standard(default) visual editor, 2 - full visual editor, 3 - mini visual editor.
+ * - PerView - number of comments on a page
+ * - IsRatable - 0 or 1 allow to rate comments or not
+ * - ViewingThreshold - comment viewing treshost, if comment is below this number it is hidden by default
+ * - IsOn - is this comment object enabled
+ * - RootStylePrefix - toot comments style prefix, if you need root comments look different\
+ * - ObjectVote - Vote object name to process comments' votes. May be empty if Comment Vote is not needed.
+ * - TriggerTable - table to be updated upon each comment
+ * - TriggerFieldId - TriggerTable table field with unique record id of
+ * - TriggerFieldComments - TriggerTable table field with comments count, it will be updated automatically upon eaech comment
+ * - ClassName - your custom class name if you need to override default class, this class must have the same constructor arguments
+ * - ClassFile - file where your ClassName is stored.
+ *
+ * You can refer to BoonEx modules for sample record in this table.
+ *
+ *
+ *
+ * @section example Example of usage:
+ * After filling in the table you can show comments section in any place, using the following code:
+ *
+ * @code
+ * $o = new BxTemplCmts('value of ObjectName field', $iYourEntryId);
+ * if ($o->isEnabled())
+ *     echo $o->getCommentsBlock ();
+ * @endcode
+ *
+ * Please note that you never need to use BxDolCmts class directly, use BxTemplCmts instead.
+ * Also if you override comments class with your own then make it child of BxTemplCmts class.
+ *
+ *
+ *
+ * @section acl Memberships/ACL:
+ * - comments post
+ * - comments edit own
+ * - comments remove own
+ * - comments edit all
+ *
+ *
+ *
+ * @section alerts Alerts:
+ * Alerts type/unit - every module has own type/unit, it equals to ObjectName.
+ *
+ * The following alerts are rised
+ *
+ * - commentPost - comment was posted
+ *      - $iObjectId - entry id
+ *      - $iSenderId - author of comment
+ *      - $aExtra['comment_id'] - just added comment id
+ *
+ * - commentRemoved - comments was removed
+ *      - $iObjectId - entry id
+ *      - $iSenderId - comment deleter id
+ *      - $aExtra['comment_id'] - removed comment id
+ *
+ * - commentUpdated - comments was updated
+ *      - $iObjectId - entry id
+ *      - $iSenderId - comment deleter id
+ *      - $aExtra['comment_id'] - updated comment id
+ *
+ * - commentRated - comments was rated
+ *      - $iObjectId - entry id
+ *      - $iSenderId - comment rater id
+ *      - $aExtra['comment_id'] - rated comment id
+ *      - $aExtra['rate'] - comment rate 1 or -1
+ *
+ */
+class BxDolCmts extends BxDolFactory implements iBxDolReplaceable, iBxDolContentInfoService
+{
+    /**
+     * System tables which are UNITED for all 
+     * comment systems and cannot be overwritten.
+     */
+    public static $sTableSystems = 'sys_objects_cmts';
+    public static $sTableIds = 'sys_cmts_ids';
+
+    /**
+     * System tables which are used by default and
+     * can be overwritten in comment systems.
+     * @see BxDolCmts::getTableNameImages and BxDolCmts::getTableNameImages2Entries
+     */
+    protected $_sTableImages = 'sys_cmts_images';
+    protected $_sTableImages2Entries = 'sys_cmts_images2entries';
+
+    protected $_aElementDefaults;
+    protected $_aElementDefaultsApi;
+    protected $_aElementParamsApi; //--- Params from DefaultsApi array to be passed to Api
+
+    protected $_bIsApi;
+
+    protected $_sType;
+    protected $_oQuery = null;
+    protected $_oTemplate = null;
+
+    protected $_bPostQuote;
+    protected $_bMinPostForm;
+    protected $_sFormObject;
+    protected $_sFormDisplayPost;
+    protected $_sFormDisplayEdit;
+
+    protected $_sConnObjFriends;
+    protected $_sConnObjSubscriptions;
+
+    protected $_sMenuObjManage;
+    protected $_sMenuObjActions;
+    protected $_sMenuObjCounters;
+    protected $_sMenuObjMeta;
+
+    protected $_sMetatagsObj;
+
+    protected $_sViewUrl = '';
+    protected $_sBaseUrl = '';
+    protected $_sListAnchor = '';
+    protected $_sItemAnchor = '';
+
+    protected $_aSystems = [];
+
+    protected $_sSystem = 'profile'; ///< current comment system name
+    protected $_aSystem = []; ///< current comments system array
+    protected $_iId = 0; ///< obect id to be commented
+    protected $_iAuthorId = 0; ///< currently logged in user who browse, post, etc.
+
+    protected $_aParams = [];
+    protected $_aT = []; ///< an array of lang keys
+    protected $_aMarkers = [];
+
+    protected $_sDisplayType = '';
+    protected $_sDpSessionKey = '';
+    protected $_iDpMaxLevel = 0;
+
+    protected $_sBrowseType = '';
+    protected $_bBrowseFilter = false;
+    protected $_sBrowseFilter = '';
+    protected $_sBpSessionKeyType = '';
+    protected $_sBpSessionKeyFilter = '';
+    protected $_aOrder = array();
+
+    protected $_sSnippetLenthLiveSearch = 50;
+
+    protected $_iRememberTime = 2592000;
+
+    protected $_bLiveUpdates = true;
+
+    /**
+     * Constructor
+     * $sSystem - comments system name
+     * $iId - obect id to be commented
+     */
+    protected function __construct($sSystem, $iId, $iInit = true, $oTemplate = false)
+    {
+        parent::__construct();
+
+        $this->_bIsApi = bx_is_api();
+
+        $this->_sType = BX_DOL_CMT_TYPE_COMMENTS;
+
+        $this->_aSystems = $this->getSystems();
+        if(!isset($this->_aSystems[$sSystem]))
+            return;
+
+        $this->_sSystem = $sSystem;
+        $this->_aSystem = $this->_aSystems[$sSystem];
+        $this->_aSystem['is_browse_filter'] = (int)$this->_bBrowseFilter;
+
+        $this->_iAuthorId = $this->_getAuthorId();
+
+        $this->_iDpMaxLevel = (int)$this->_aSystem['number_of_levels'];
+        $this->_sDisplayType = $this->_iDpMaxLevel == 0 ? BX_CMT_DISPLAY_FLAT : BX_CMT_DISPLAY_THREADED;
+        $this->_sDpSessionKey = 'bx_' . $this->_sSystem . '_dp_';
+
+        $this->_sBrowseType = $this->_aSystem['browse_type'];
+        $this->_sBrowseFilter = BX_CMT_FILTER_ALL;
+        $this->_sBpSessionKeyType = 'bx_' . $this->_sSystem . '_bpt_';
+        $this->_sBpSessionKeyFilter = 'bx_' . $this->_sSystem . '_bpf_';
+        $this->_aOrder = array(
+            'by' => BX_CMT_ORDER_BY_DATE,
+            'way' => BX_CMT_ORDER_WAY_ASC
+        );
+
+        list($mixedUserDp, $mixedUserBpType, $mixedUserBpFilter) = $this->_getUserChoice();
+        if(!empty($mixedUserDp))
+            $this->_sDisplayType = $mixedUserDp;
+        if(!empty($mixedUserBpType))
+            $this->_sBrowseType = $mixedUserBpType;
+        if(!empty($mixedUserBpFilter))
+            $this->_sBrowseFilter = $mixedUserBpFilter;
+
+        $this->_sViewUrl = 'page.php?i=cmts-view';
+        $this->_sBaseUrl = $this->_aSystem['base_url'];
+
+        $this->_sListAnchor = "cmts-anchor-%s-%d";
+        $this->_sItemAnchor = "cmt-anchor-%s-%d-%d";
+
+        $this->_oQuery = new BxDolCmtsQuery($this);
+
+        $this->_bPostQuote = false;
+        $this->_bMinPostForm = true;
+        $this->_sFormObject = 'sys_comment';
+        $this->_sFormDisplayPost = 'sys_comment_post';
+        $this->_sFormDisplayEdit = 'sys_comment_edit';
+
+        $this->_sConnObjFriends = 'sys_profiles_friends';
+        $this->_sConnObjSubscriptions = 'sys_profiles_subscriptions';
+
+        $this->_sMenuObjManage = 'sys_cmts_item_manage';
+        $this->_sMenuObjActions = 'sys_cmts_item_actions';
+        $this->_sMenuObjCounters = 'sys_cmts_item_counters';
+        $this->_sMenuObjMeta = 'sys_cmts_item_meta';
+
+        $this->_sMetatagsObj = 'sys_cmts';
+
+        if(($aParams = bx_get('params')) !== false) {
+            if(is_string($aParams))
+                $aParams = json_decode($aParams, true);
+            if(!empty($aParams) && is_array($aParams))
+                $this->_aParams = array_merge($this->_aParams, $aParams);
+        }
+
+        $this->_aT = [
+            'block_comments_title' => '_cmt_block_comments_title',
+            'txt_sample_single' => '_cmt_txt_sample_comment_single',
+            'txt_sample_vote_single' => '_cmt_txt_sample_vote_single',
+            'txt_sample_reaction_single' => '_cmt_txt_sample_reaction_single',
+            'txt_sample_score_up_single' => '_cmt_txt_sample_score_up_single',
+            'txt_sample_score_down_single' => '_cmt_txt_sample_score_down_single',
+            'txt_min_form_placeholder' => '_cmt_txt_min_form_placeholder'
+        ];
+
+        if ($iInit)
+            $this->init($iId);
+
+        if ($oTemplate)
+            $this->_oTemplate = $oTemplate;
+        else
+            $this->_oTemplate = BxDolTemplate::getInstance();
+    }
+
+    /**
+     * get comments object instanse
+     * @param $sSys comments object name
+     * @param $iId associated content id, where comments are postred in
+     * @param $iInit perform initialization
+     * @return null on error, or ready to use class instance
+     */
+    public static function getObjectInstance($sSys, $iId, $iInit = true, $oTemplate = false)
+    {
+        if(isset($GLOBALS['bxDolClasses']['BxDolCmts!' . $sSys . $iId]))
+            return $GLOBALS['bxDolClasses']['BxDolCmts!' . $sSys . $iId];
+
+        $aSystems = self::getSystems();
+        if (!isset($aSystems[$sSys]))
+            return null;
+
+        $sClassName = 'BxTemplCmts';
+        if(!empty($aSystems[$sSys]['class_name'])) {
+            $sClassName = $aSystems[$sSys]['class_name'];
+            if(!empty($aSystems[$sSys]['class_file']))
+                require_once(BX_DIRECTORY_PATH_ROOT . $aSystems[$sSys]['class_file']);
+        }
+
+        $o = new $sClassName($sSys, $iId, $iInit, $oTemplate);
+        return ($GLOBALS['bxDolClasses']['BxDolCmts!' . $sSys . $iId] = $o);
+    }
+    
+    /**
+     * get comments object instanse
+     * @param $iUniqId unique comment id
+     * @return null on error, or ready to use class instance
+     */
+    public static function getObjectInstanceByUniqId($iUniqId, $iInit = true, $oTemplate = false)
+    {
+        $aData = BxDolCmtsQuery::getInfoByUniqId($iUniqId);
+        if(empty($aData) || !is_array($aData))
+            return null;
+
+        return self::getObjectInstance($aData['system_name'], $aData['cmt_object_id']);
+    }
+
+    public static function &getSystems ()
+    {
+        $sKey = 'bx_dol_cache_memory_cmts_systems';
+
+        if (!isset($GLOBALS[$sKey])) {
+            $GLOBALS[$sKey] = BxDolDb::getInstance()->fromCache('sys_objects_cmts', 'getAllWithKey', '
+                SELECT
+                    `ID` as `system_id`,
+                    `Name` AS `name`,
+                    `Module` AS `module`,
+                    `Table` AS `table`,
+                    `CharsPostMin` AS `chars_post_min`,
+                    `CharsPostMax` AS `chars_post_max`,
+                    `CharsDisplayMax` AS `chars_display_max`,
+                    `Html` AS `html`,
+                    `PerView` AS `per_view`,
+                    `PerViewReplies` AS `per_view_replies`,
+                    `BrowseType` AS `browse_type`,
+                    `IsBrowseSwitch` AS `is_browse_switch`,
+                    `PostFormPosition` AS `post_form_position`,
+                    `NumberOfLevels` AS `number_of_levels`,
+                    `IsDisplaySwitch` AS `is_display_switch`,
+                    `IsRatable` AS `is_ratable`,
+                    `ViewingThreshold` AS `viewing_threshold`,
+                    `IsOn` AS `is_on`,
+                    `RootStylePrefix` AS `root_style_prefix`,
+                    `BaseUrl` AS `base_url`,
+                    `ObjectVote` AS `object_vote`,
+                    `ObjectReaction` AS `object_reaction`,
+                    `ObjectScore` AS `object_score`,
+                    `ObjectReport` AS `object_report`,
+                    `TriggerTable` AS `trigger_table`,
+                    `TriggerFieldId` AS `trigger_field_id`,
+                    `TriggerFieldAuthor` AS `trigger_field_author`,
+                    `TriggerFieldTitle` AS `trigger_field_title`,
+                    `TriggerFieldComments` AS `trigger_field_comments`,
+                    `ClassName` AS `class_name`,
+                    `ClassFile` AS `class_file`
+                FROM `' . self::$sTableSystems . '`', 'name');
+        }
+        return $GLOBALS[$sKey];
+    }
+
+    public static function getGlobalInfo ($iUniqueId)
+    {
+        return BxDolDb::getInstance()->getRow("SELECT `ti`.*, `ts`.`Name` AS `system_name` FROM `" . self::$sTableIds . "` AS `ti` LEFT JOIN `" . self::$sTableSystems . "` AS `ts` ON `ti`.`system_id`=`ts`.`ID` WHERE `ti`.`id`=:id LIMIT 1", array(
+            'id' => (int)$iUniqueId
+        ));
+    }
+
+    public static function getGlobalNumByParams($aParams = [])
+    {
+        $sQuery = "SELECT COUNT(*) FROM `" . self::$sTableIds . "` WHERE 1";
+        foreach($aParams as $aValue)
+            $sQuery .= " AND `" . $aValue['key'] ."` " . $aValue['operator'] . " '" . $aValue['value'] . "'";
+
+        return BxDolDb::getInstance()->getOne($sQuery);
+    }
+
+    public function init ($iId)
+    {
+        if (empty($this->iId) && $iId)
+            $this->setId($iId);
+
+        $this->addMarkers(array(
+            'object_id' => $this->getId(),
+            'user_id' => $this->_getAuthorId()
+        ));
+    }
+
+    public function setParam($sName, $sValue)
+    {
+        $this->_aParams[$sName] = $sValue;
+    }
+
+    public function isParam($sName)
+    {
+        return isset($this->_aParams[$sName]);
+    }
+
+    public function getParam($sName)
+    {
+        return $this->_aParams[$sName];
+    }
+
+    public function getId ()
+    {
+        return $this->_iId;
+    }
+
+    public function isEnabled ()
+    {
+        return isset($this->_aSystem['is_on']) && $this->_aSystem['is_on'];
+    }
+
+    public function getSystemId()
+    {
+        return $this->_aSystem['system_id'];
+    }
+
+    public function getSystemName()
+    {
+        return $this->_sSystem;
+    }
+
+    public function getSystemInfo()
+    {
+        return $this->_aSystem;
+    }
+
+    public function getStorageObjectName()
+    {
+    	return $this->_getFormObject()->getStorageObjectName();
+    }
+
+    public function getTranscoderPreviewName()
+    {
+    	return $this->_getFormObject()->getTranscoderPreviewName();
+    }
+    
+    public function getFormObject()
+    {
+    	return $this->_getFormObject();
+    }
+
+    public function getTableNameImages()
+    {
+        return $this->_sTableImages;
+    }
+
+    public function getTableNameImages2Entries()
+    {
+        return $this->_sTableImages2Entries;
+    }
+
+    public function getLanguageKey($sIndex)
+    {
+        return isset($this->_aT[$sIndex]) ? $this->_aT[$sIndex] : '';
+    }
+
+    public function getMaxLevel()
+    {
+        return $this->_iDpMaxLevel;
+    }
+
+    public function getOrder ()
+    {
+        return $this->_sOrder;
+    }
+
+    public function getPerView ($iCmtParentId = 0)
+    {
+        return $iCmtParentId == 0 ? $this->_aSystem['per_view'] : $this->_aSystem['per_view_replies'];
+    }
+
+    public function getStatusAdmin()
+    {
+        return $this->isEditAllowedAll() || $this->isRemoveAllowedAll() || $this->isAutoApprove() ? BX_CMT_STATUS_ACTIVE : BX_CMT_STATUS_PENDING;
+    }
+
+    public function getViewUrl($iCmtId, $bAbsolute = true)
+    {
+    	if(empty($this->_aSystem['trigger_field_title']))
+            return '';
+
+        $s = BxDolPermalinks::getInstance()->permalink($this->_sViewUrl, [
+            'sys' => $this->_sSystem,
+            'id' => $this->_iId,
+            'cmt_id' => $iCmtId
+        ]);
+
+    	return $bAbsolute ? bx_absolute_url($s) : $s;
+    }
+
+    public function getViewText($mixedItem)
+    {
+        if(!is_array($mixedItem))
+            $mixedItem = $this->getCommentSimple((int)$mixedItem);
+
+        if(empty($mixedItem) || !is_array($mixedItem))
+            return '';
+
+        return $this->_prepareTextForOutput($mixedItem['cmt_text'], (int)$mixedItem['cmt_id']);
+    }
+
+    public function getBaseUrl()
+    {
+        $sUrl = $this->_replaceMarkers($this->_sBaseUrl);
+        $sUrl = BxDolPermalinks::getInstance()->permalink($sUrl);
+        if(get_mb_substr($sUrl, 0, 6) != 'http:/' && get_mb_substr($sUrl, 0, 7) != 'https:/')
+            $sUrl = BX_DOL_URL_ROOT . $sUrl;
+        return $sUrl;
+    }
+
+    public function getListUrl()
+    {
+        $sBaseUrl = $this->getBaseUrl();
+        if(empty($sBaseUrl))
+            return '';
+
+        return $sBaseUrl . $this->getListAnchor(true);
+    }
+
+    public function getItemUrl($iItemId)
+    {
+        $sBaseUrl = $this->getBaseUrl();
+        if(empty($sBaseUrl))
+            return '';
+
+        return $sBaseUrl . $this->getItemAnchor($iItemId, true);
+    }
+
+    public function getListAnchor($bWithHash = false)
+    {
+        return ($bWithHash ? '#' : '') . sprintf($this->_sListAnchor, str_replace('_', '-', $this->getSystemName()), $this->getId());
+    }
+
+    public function getItemAnchor($iItemId, $bWithHash = false)
+    {
+        return ($bWithHash ? '#' : '') . sprintf($this->_sItemAnchor, str_replace('_', '-', $this->getSystemName()), $this->getId(), $iItemId);
+    }
+
+    public function getAttachments($iCmtId)
+    {
+        $aResult = array();
+
+        if(!$this->isAttachImageEnabled())
+            return $aResult; 
+
+        $aFiles = $this->_oQuery->getFiles($this->_aSystem['system_id'], $iCmtId);
+        if(empty($aFiles) || !is_array($aFiles)) 
+            return $aResult;
+
+        $oStorage = BxDolStorage::getObjectInstance($this->getStorageObjectName());
+        $oTranscoder = BxDolTranscoderImage::getObjectInstance($this->getTranscoderPreviewName());
+
+        foreach($aFiles as $aFile) {
+            $bImage = $oTranscoder && $oTranscoder->isMimeTypeSupported($aFile['mime_type']);
+
+            $sPreview = '';
+            if($bImage)
+                $sPreview = $oTranscoder->getFileUrl($aFile['image_id']);
+
+            if(!$sPreview)
+                $sPreview = $this->_oTemplate->getIconUrl($oStorage->getIconNameByFileName($aFile['file_name']));
+
+            $sUrl = $oStorage->getFileUrlById($aFile['image_id']);
+
+            $aResult[] = array(
+                'id' => $aFile['image_id'],
+                'src' => $sPreview,
+                'src_orig' => $bImage ? $sUrl : '',
+                'url' => !$bImage ? $sUrl : ''
+            );
+        }
+
+        return $aResult;
+    }
+
+    public function getConnectionObject($sType)
+    {
+        $sResult = '';
+
+        switch($sType) {
+            case BX_CMT_FILTER_FRIENDS:
+                $sResult = $this->_sConnObjFriends;
+                break;
+            case BX_CMT_FILTER_SUBSCRIPTIONS:
+                $sResult = $this->_sConnObjSubscriptions;
+                break;
+        }
+
+        return $sResult;
+    }
+
+    public function getVoteObject($iEniqId)
+    {
+        if(empty($this->_aSystem['object_vote']))
+            $this->_aSystem['object_vote'] = 'sys_cmts';
+
+        $oVote = BxDolVote::getObjectInstance($this->_aSystem['object_vote'], $iEniqId, true, $this->_oTemplate);
+        if(!$oVote || !$oVote->isEnabled())
+            return false;
+
+        return $oVote;
+    }
+
+    public function getReactionObject($iEniqId)
+    {
+        if(empty($this->_aSystem['object_reaction']))
+            $this->_aSystem['object_reaction'] = 'sys_cmts_reactions';
+
+        $oReaction = BxDolVote::getObjectInstance($this->_aSystem['object_reaction'], $iEniqId, true, $this->_oTemplate);
+        if(!$oReaction || !$oReaction->isEnabled())
+            return false;
+
+        return $oReaction;
+    }
+
+    public function getScoreObject($iEniqId)
+    {
+        if(empty($this->_aSystem['object_score']))
+            $this->_aSystem['object_score'] = 'sys_cmts';
+
+        $oScore = BxDolScore::getObjectInstance($this->_aSystem['object_score'], $iEniqId, true, $this->_oTemplate);
+        if(!$oScore || !$oScore->isEnabled())
+            return false;
+
+        return $oScore;
+    }
+
+    public function getReportObject($iEniqId)
+    {
+        if(empty($this->_aSystem['object_report']))
+            $this->_aSystem['object_report'] = 'sys_cmts';
+
+        $oReport = BxDolReport::getObjectInstance($this->_aSystem['object_report'], $iEniqId, true, $this->_oTemplate);
+        if(!$oReport || !$oReport->isEnabled())
+            return false;
+
+        return $oReport;
+    }
+
+    /**
+     * Is used as: 
+     * 1. Live Update's session key.
+     * 2. HTML ID for Live Update's 'New Content' button.
+     * @return string with ID.
+     */
+    public function getNotificationId()
+    {
+        return 'cmts-notification-' . $this->_sSystem . '-' . $this->_iId;
+    }
+
+    public function getSocketName()
+    {
+        return 'cmts_' . $this->_sSystem;
+    }
+
+    /**
+     * @deprecated since version 10.0.0-B3 and can be removed in later versions.
+     */
+    public function setTableNameFiles($sTable)
+    {
+        $this->_sTableImages = $sTable;
+    	$this->_oQuery->setTableNameFiles($sTable);
+    }
+
+    /**
+     * @deprecated since version 10.0.0-B3 and can be removed in later versions.
+     */
+    public function setTableNameFiles2Entries($sTable)
+    {
+    	$this->_sTableImages2Entries = $sTable;
+    	$this->_oQuery->setTableNameFiles2Entries($sTable);
+    }
+
+    public function isHtml ()
+    {
+        return $this->_aSystem['html'] > 0;
+    }
+
+    public function isRatable ()
+    {
+        return $this->_aSystem['is_ratable'];
+    }
+
+    public function isAttachImageEnabled()
+    {
+        return true;
+    }
+
+    public function isAutoApprove()
+    {
+        return getParam('sys_cmts_enable_auto_approve') == 'on';
+    }
+
+    /**
+     * set id to operate with votes
+     */
+    public function setId ($iId)
+    {
+        if ($iId == $this->getId()) return;
+        $this->_iId = $iId;
+    }
+
+    /**
+     * Add replace markers.
+     * @param $a array of markers as key => value
+     * @return true on success or false on error
+     */
+    public function addMarkers ($a)
+    {
+        if (empty($a) || !is_array($a))
+            return false;
+        $this->_aMarkers = array_merge ($this->_aMarkers, $a);
+        return true;
+    }
+
+    /**
+     * Database functions
+     */
+    public function getQueryObject ()
+    {
+        return $this->_oQuery;
+    }
+
+    public function getCommentsTableName ()
+    {
+        return $this->_oQuery->getTableName ();
+    }
+
+    public function getObjectAuthorId ($iObjectId = 0)
+    {
+    	if(empty($this->_aSystem['trigger_field_author']))
+    		return 0;
+
+        return $this->_oQuery->getObjectAuthorId ($iObjectId ? $iObjectId : $this->getId());
+    }
+
+    public function getObjectTitle ($iObjectId = 0)
+    {
+        if(empty($this->_aSystem['trigger_field_title']))
+            return '';
+
+        $sTitle = $this->_oQuery->getObjectTitle ($iObjectId ? $iObjectId : $this->getId());
+        if(get_mb_substr($sTitle, 0, 1) == '_')
+            $sTitle = _t($sTitle);
+
+        return $sTitle;
+    }
+    
+    public function getObjectPrivacyView ($iObjectId = 0)
+    {
+        if(empty($iObjectId))
+            $iObjectId = $this->getId();
+
+        $sFieldPrivacyView = '';
+        if(!empty($this->_aSystem['trigger_field_privacy_view']))
+            $sFieldPrivacyView = $this->_aSystem['trigger_field_privacy_view'];
+
+        if(($iPrivacyView = $this->_oQuery->getObjectPrivacyView($iObjectId, $sFieldPrivacyView)) !== false)
+            return $iPrivacyView;
+
+        return BX_DOL_PG_ALL;
+    }
+
+    /*
+     * Gets Content Filter object.
+     */
+    public function getObjectContentFilter ()
+    {
+        $oCf = BxDolContentFilter::getInstance();
+        if(!$oCf->isEnabledForComments())
+            return false;
+
+        return $oCf;
+    }
+
+    public function getCommentsCountAll ($iObjectId = 0, $bForceCalculate = false)
+    {
+        return $this->_oQuery->getCommentsCountAll ($iObjectId ? $iObjectId : $this->getId(), $this->_getAuthorId(), $bForceCalculate);
+    }
+
+    public function getCommentsCount ($iObjectId = 0, $iCmtVParentId = -1, $sFilter = '')
+    {
+        return $this->_oQuery->getCommentsCount ($iObjectId ? $iObjectId : $this->getId(), $iCmtVParentId, $this->_getAuthorId(), $sFilter);
+    }
+
+    public function getCommentsArray ($iVParentId, $sFilter, $aOrder, $iStart = 0, $iCount = -1)
+    {
+        return $this->_oQuery->getComments ($this->getId(), $iVParentId, $this->_getAuthorId(), $sFilter, $aOrder, $iStart, $iCount);
+    }
+
+    public function getCommentsBy ($aParams = [])
+    {
+        return $this->_oQuery->getCommentsBy($aParams); 
+    }
+
+    /**
+     * Get comment's unique id.
+     */
+    public function getCommentUniqId($iCmtId, $iAuthorId = 0)
+    {
+        return $this->_oQuery->getUniqId($this->getSystemId(), $iCmtId, ['author_id' => $iAuthorId]);
+    }
+
+    /**
+     * Get comment's short info.
+     */
+    public function getCommentSimple ($iCmtId)
+    {
+        return $this->_oQuery->getCommentSimple ($this->getId(), $iCmtId);
+    }
+
+    /**
+     * Get comment's full info.
+     */
+    public function getCommentRow ($iCmtId)
+    {
+        return $this->_oQuery->getComment ($this->getId(), $iCmtId);
+    }
+
+    public function getCommentParents ($mixedCmt, $iDepthMax = 0)
+    {
+        if(!is_array($mixedCmt))
+            $mixedCmt = $this->_oQuery->getCommentsBy(['type' => 'id', 'id' => (int)$mixedCmt]);
+
+        $iDepth = 0;
+        $aParents = [];
+        $this->_getParents($mixedCmt, $iDepthMax, $iDepth, $aParents);
+
+        return $aParents;
+    }
+
+    public function onObjectDelete ($iObjectId = 0)
+    {
+        // delete comments
+        $aFiles = $aCmtIds = [];
+        $this->_oQuery->deleteObjectComments ($iObjectId ? $iObjectId : $this->getId(), $aFiles, $aCmtIds);
+
+        // delete votes
+        $this->deleteVotes($aCmtIds);
+
+        // delete reactions
+        $this->deleteReactions($aCmtIds);
+
+        // delete scores
+        $this->deleteScores($aCmtIds);
+
+        // delete reports
+        $this->deleteReports($aCmtIds);
+
+        // delete meta info
+        $this->deleteMetaInfo($aCmtIds);
+
+        // delete files
+        if ($aFiles) {
+            $oStorage = BxDolStorage::getObjectInstance($this->getStorageObjectName());
+            if ($oStorage)
+                $oStorage->queueFilesForDeletion($aFiles);
+        }
+
+        // delete unique IDs
+        $this->deleteUniqueIds($aCmtIds);
+    }
+
+    public static function onAuthorDelete ($iAuthorId)
+    {
+        $aSystems = self::getSystems();
+        foreach($aSystems as $sSystem => $aSystem) {
+            $o = self::getObjectInstance($sSystem, 0);
+            $oQuery = $o->getQueryObject();
+
+            // delete comments
+            $aFiles = $aCmtIds = array ();
+            $oQuery->deleteAuthorComments($iAuthorId, $aFiles, $aCmtIds);
+
+            // delete votes
+            $o->deleteVotes($aCmtIds);
+
+            // delete reactions
+            $o->deleteReactions($aCmtIds);
+
+            // delete scores
+            $o->deleteScores($aCmtIds);
+
+            // delete reports
+            $o->deleteReports($aCmtIds);
+
+            // delete meta info
+            $o->deleteMetaInfo($aCmtIds);
+    
+            // delete files
+            $oStorage = BxDolStorage::getObjectInstance($o->getStorageObjectName());
+            if ($oStorage)
+                $oStorage->queueFilesForDeletion($aFiles);
+            
+            // delete unique IDs
+            $o->deleteUniqueIds($aCmtIds);
+        }
+        return true;
+    }
+
+    public static function onModuleEnable ($sModuleName)
+    {
+        $aSystems = self::getSystems();
+        foreach($aSystems as $sSystem => $aSystem) {
+            if ($sModuleName !== $aSystem['module'])
+                continue;
+
+            $o = self::getObjectInstance($sSystem, 0);
+            $o->registerTranscoders();
+        }
+
+        return true;
+    }
+
+    public static function onModuleDisable ($sModuleName)
+    {
+        $aSystems = self::getSystems();
+        foreach($aSystems as $sSystem => $aSystem) {
+            if ($sModuleName !== $aSystem['module'])
+                continue;
+
+            $o = self::getObjectInstance($sSystem, 0);
+            $o->unregisterTranscoders();
+        }
+
+        return true;
+    }
+
+    public static function onModuleUninstall ($sModuleName, &$iFiles = null)
+    {
+        $aSystems = self::getSystems();
+        foreach($aSystems as $sSystem => $aSystem) {
+            if ($sModuleName !== $aSystem['module'])
+                continue;
+
+            $o = self::getObjectInstance($sSystem, 0);
+            $oQuery = $o->getQueryObject();
+
+            // delete comments
+            $aFiles = $aCmtIds = array ();
+            $oQuery->deleteAll($aSystem['system_id'], $aFiles, $aCmtIds);
+
+            // delete votes
+            $o->deleteVotes($aCmtIds);
+
+            // delete reactions
+            $o->deleteReactions($aCmtIds);
+
+            // delete scores
+            $o->deleteScores($aCmtIds);
+
+            // delete reports
+            $o->deleteReports($aCmtIds);
+
+            // delete meta info
+            $o->deleteMetaInfo($aCmtIds);
+
+            // delete files
+            $oStorage = BxDolStorage::getObjectInstance($o->getStorageObjectName());
+            if ($oStorage && $aFiles)
+                $oStorage->queueFilesForDeletion($aFiles);
+
+            if (null !== $iFiles)
+                $iFiles += count($aFiles);
+
+            // delete unique IDs
+            $o->deleteUniqueIds($aCmtIds);
+        }
+
+        return true;
+    }
+
+    public function deleteVotes ($mixedCmtId)
+    {
+        if(!is_array($mixedCmtId))
+            $mixedCmtId = [$mixedCmtId];
+
+        foreach($mixedCmtId as $iCmtId)
+            if(($oReport = $this->getVoteObject($this->getCommentUniqId($iCmtId))) !== false)
+                $oReport->onObjectDelete();
+    }
+
+    public function deleteReactions ($mixedCmtId)
+    {
+        if(!is_array($mixedCmtId))
+            $mixedCmtId = [$mixedCmtId];
+
+        foreach($mixedCmtId as $iCmtId)
+            if(($oReport = $this->getReactionObject($this->getCommentUniqId($iCmtId))) !== false)
+                $oReport->onObjectDelete();
+    }
+
+    public function deleteScores ($mixedCmtId)
+    {
+        if(!is_array($mixedCmtId))
+            $mixedCmtId = [$mixedCmtId];
+
+        foreach($mixedCmtId as $iCmtId)
+            if(($oReport = $this->getScoreObject($this->getCommentUniqId($iCmtId))) !== false)
+                $oReport->onObjectDelete();
+    }
+
+    public function deleteReports ($mixedCmtId)
+    {
+        if(!is_array($mixedCmtId))
+            $mixedCmtId = [$mixedCmtId];
+
+        foreach($mixedCmtId as $iCmtId)
+            if(($oReport = $this->getReportObject($this->getCommentUniqId($iCmtId))) !== false)
+                $oReport->onObjectDelete();
+    }
+
+    public function deleteMetaInfo ($mixedCmtId)
+    {
+        if(!is_array($mixedCmtId))
+            $mixedCmtId = array($mixedCmtId);
+
+        $oMetatags = false;
+        if(!empty($this->_sMetatagsObj))
+            $oMetatags = BxDolMetatags::getObjectInstance($this->_sMetatagsObj);
+
+        if($oMetatags)
+            foreach($mixedCmtId as $iCmtId)
+                $oMetatags->onDeleteContent($this->getCommentUniqId($iCmtId));
+    }
+
+    public function deleteUniqueIds ($mixedCmtId)
+    {
+        if(!is_array($mixedCmtId))
+            $mixedCmtId = array($mixedCmtId);
+
+        foreach($mixedCmtId as $iCmtId)
+            $this->_oQuery->deleteCmtIds($this->_aSystem['system_id'], $iCmtId);
+    }
+
+    /**
+     * Permissions functions
+     */
+    public function isAdmin($iCmtAuthorId)
+    {
+        return BxDolAcl::getInstance()->isMemberLevelInSet(array(MEMBERSHIP_ID_MODERATOR, MEMBERSHIP_ID_ADMINISTRATOR), $iCmtAuthorId);
+    }
+
+    public function checkAction ($sAction, $isPerformAction = false)
+    {
+        $iId = $this->_getAuthorId();
+        $a = checkActionModule($iId, $sAction, 'system', $isPerformAction);
+        return $a[CHECK_ACTION_RESULT] === CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    public function checkActionErrorMsg ($sAction)
+    {
+        $iId = $this->_getAuthorId();
+        $a = checkActionModule($iId, $sAction, 'system');
+        return $a[CHECK_ACTION_RESULT] !== CHECK_ACTION_RESULT_ALLOWED ? $a[CHECK_ACTION_MESSAGE] : '';
+    }
+
+    public function isViewAllowed ($isPerformAction = false)
+    {
+        if (BxDolRequest::serviceExists($this->_aSystem['module'], 'check_allowed_comments_view')) {
+            $mixedResult = BxDolService::call($this->_aSystem['module'], 'check_allowed_comments_view', array($this->getId(), $this->getSystemName()));
+            if($mixedResult !== CHECK_ACTION_RESULT_ALLOWED)
+                return $mixedResult;
+        }
+
+        return CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    public function isVoteAllowed ($aCmt, $isPerformAction = false)
+    {
+        if(!$this->isRatable())
+            return false;
+
+        $oVote = $this->getVoteObject($this->getCommentUniqId($aCmt['cmt_id']));
+        if($oVote === false)
+            return false;
+
+        $iUserId = (int)$this->_getAuthorId();
+        if($iUserId == 0)
+            return false;
+
+        if(isAdmin())
+            return true;
+
+        return $oVote->isAllowedVote($isPerformAction);
+    }
+
+    public function isScoreAllowed ($aCmt, $isPerformAction = false)
+    {
+        if(!$this->isRatable())
+            return false;
+
+        $oScore = $this->getScoreObject($this->getCommentUniqId($aCmt['cmt_id']));
+        if($oScore === false)
+            return false;
+
+        $iUserId = (int)$this->_getAuthorId();
+        if($iUserId == 0)
+            return false;
+
+        if(isAdmin())
+            return true;
+
+        return $oScore->isAllowedVote($isPerformAction);
+    }
+
+    public function isReportAllowed ($aCmt, $isPerformAction = false)
+    {
+        $oReport = $this->getReportObject($aCmt['cmt_id']);
+        if($oReport === false)
+            return false;
+
+        $iUserId = (int)$this->_getAuthorId();
+        if($iUserId == 0)
+            return false;
+
+        if(isAdmin())
+            return true;
+
+        return $oReport->isAllowedReport($isPerformAction);
+    }
+
+    public function isPostAllowed ($isPerformAction = false)
+    {
+        if (BxDolRequest::serviceExists($this->_aSystem['module'], 'check_allowed_comments_post')) {
+            $mixedResult = BxDolService::call($this->_aSystem['module'], 'check_allowed_comments_post', array($this->getId(), $this->getSystemName()));
+            if($mixedResult !== CHECK_ACTION_RESULT_ALLOWED)
+                return false;
+        }
+
+        return $this->checkAction ('comments post', $isPerformAction);
+    }
+
+    public function msgErrPostAllowed ()
+    {
+        return $this->checkActionErrorMsg('comments post');
+    }
+
+    /**
+     * Determines whether a 'reply' action allowed or not.
+     * @param integer or array $mixedCmt - ID or an array which describes the comment to be replied
+     * @param boolean $isPerformAction
+     * @return boolean
+     */
+    public function isReplyAllowed ($mixedCmt, $isPerformAction = false)
+    {
+        return $this->isPostAllowed($isPerformAction);
+    }
+
+    public function msgErrReplyAllowed ()
+    {
+        return $this->msgErrPostAllowed ();
+    }
+
+    public function isQuoteAllowed ($mixedCmt, $isPerformAction = false)
+    {
+        return $this->_bPostQuote && $this->isReplyAllowed ($mixedCmt, $isPerformAction);
+    }
+
+    public function msgErrQuoteAllowed ()
+    {
+        return $this->msgErrReplyAllowed ();
+    }
+
+    public function isPinAllowed ($aCmt, $isPerformAction = false)
+    {
+        if((int)$aCmt['cmt_parent_id'] != 0 || (int)$aCmt['cmt_pinned'] != 0)
+            return false;
+
+        if(isAdmin())
+            return true;
+        
+        return $this->checkAction ('comments pin', $isPerformAction);
+    }
+
+    public function msgErrPinAllowed ()
+    {
+        return $this->checkActionErrorMsg('comments pin');
+    }
+
+    public function isUnpinAllowed ($aCmt, $isPerformAction = false)
+    {
+        if((int)$aCmt['cmt_pinned'] == 0)
+            return false;
+
+        if(isAdmin())
+            return true;
+        
+        return $this->checkAction ('comments pin', $isPerformAction);
+    }
+
+    public function msgErrUnpinAllowed ()
+    {
+        return $this->checkActionErrorMsg('comments pin');
+    }
+
+    public function isEditAllowed ($aCmt, $isPerformAction = false)
+    {
+        if(isAdmin())
+            return true;
+
+        if($this->isEditAllowedAll($isPerformAction))
+            return true;
+
+        $mixedResult = BxDolService::call($this->_aSystem['module'], 'check_allowed_comments_post', array($this->getId(), $this->getSystemName()));
+        if($mixedResult !== CHECK_ACTION_RESULT_ALLOWED)
+            return false;
+
+        return abs($aCmt['cmt_author_id']) == $this->_getAuthorId() && $this->checkAction ('comments edit own', $isPerformAction);
+    }
+
+    public function msgErrEditAllowed ()
+    {
+        return $this->checkActionErrorMsg ('comments edit own');
+    }
+
+    public function isEditAllowedAll ($isPerformAction = false)
+    {
+        return $this->checkAction('comments edit all', $isPerformAction);
+    }
+
+    public function isRemoveAllowed ($aCmt, $isPerformAction = false)
+    {
+        if(isAdmin())
+            return true;
+
+        if($this->isRemoveAllowedAll($isPerformAction))
+            return true;
+
+        if(abs($aCmt['cmt_author_id']) == $this->_getAuthorId() && $this->checkAction ('comments remove own', $isPerformAction))
+            return true;
+        
+        $aContentInfo = BxDolRequest::serviceExists($this->_aSystem['module'], 'get_all') ? BxDolService::call($this->_aSystem['module'], 'get_all', array(array('type' => 'id', 'id' => $this->getId()))) : array();
+        $oModule = BxDolModule::getInstance($this->_aSystem['module']);
+        $CNF = $oModule->_oConfig->CNF;
+        
+        if (isset($CNF['FIELD_AUTHOR']) && $aContentInfo[$CNF['FIELD_AUTHOR']] == $this->_getAuthorId() && $this->checkAction('comments remove in own content', $isPerformAction)){
+            return true;
+        }
+        
+        if (isset($CNF['FIELD_ALLOW_VIEW_TO']) &&  $aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']] < 0){
+            $iGroupProfileId = -(int)$aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']];
+            $oProfileContext = BxDolProfile::getInstance($iGroupProfileId);
+            $oModule = BxDolModule::getInstance($oProfileContext->getModule()); 
+            if ($oModule->_oConfig->isRoles() && $oModule->getRole($iGroupProfileId, $this->_getAuthorId()) === BX_BASE_MOD_GROUPS_ROLE_ADMINISTRATOR && $this->checkAction('comments remove in group context', $isPerformAction)){
+                return true;
+            }
+        }
+        
+        return false;
+    }
+
+    public function isRemoveAllowedAll ($isPerformAction = false)
+    {
+        return $this->checkAction ('comments remove all', $isPerformAction);
+    }
+
+    public function msgErrRemoveAllowed ()
+    {
+        return $this->checkActionErrorMsg('comments remove own');
+    }
+
+    public function isMoreAllowed ($aCmt, $isPerformAction = false)
+    {
+        $oMenuManage = BxDolMenu::getObjectInstance($this->_sMenuObjManage);
+        $oMenuManage->setCmtsData($this, $aCmt['cmt_id']);
+    	return $oMenuManage->isVisible();
+    }
+
+    public function isModerator($isPerformAction = false)
+    {
+        return $this->isEditAllowedAll($isPerformAction) || $this->isRemoveAllowedAll($isPerformAction);
+    }
+
+    /**
+     * Actions functions
+     */
+    public function actionPin ()
+    {
+        if (!$this->isEnabled())
+            return echoJson(array());
+
+        $iCmtId = 0;
+        if(bx_get('Cmt') !== false)
+            $iCmtId = bx_process_input(bx_get('Cmt'), BX_DATA_INT);
+
+        $aCmt = $this->getCommentRow($iCmtId);
+        if(empty($aCmt) || !is_array($aCmt))
+            return echoJson(array());
+
+        $iWay = 0;
+        if(bx_get('way') !== false)
+            $iWay = bx_process_input(bx_get('way'), BX_DATA_INT);
+
+        if(!$this->_oQuery->updateComments(array('cmt_pinned' => $iWay ? time() : 0), array('cmt_id' => $iCmtId)))
+            return echoJson(array());
+
+        $aBp = $aDp = array();
+        $this->_getParams($aBp, $aDp);
+        $this->_prepareParams($aBp, $aDp);
+
+        echoJson(array(
+            'parent_id' => $aCmt['cmt_parent_id'],
+            'per_view' => $aBp['per_view']
+        ));
+    }
+    
+    public function actionGetFormPost ()
+    {
+        if (!$this->isEnabled())
+            return '';
+
+        $iCmtParentId = bx_get('CmtParent');
+        $iCmtParentId = $iCmtParentId !== false ? bx_process_input($iCmtParentId, BX_DATA_INT) : 0;
+        
+        $sCmtBrowse = bx_get('CmtBrowse');
+        $sCmtBrowse = $sCmtBrowse !== false ? bx_process_input($sCmtBrowse, BX_DATA_TEXT) : '';
+
+        $sCmtDisplay = bx_get('CmtDisplay');
+        $sCmtDisplay = $sCmtDisplay !== false ? bx_process_input($sCmtDisplay, BX_DATA_TEXT) : '';
+
+        $bQuote = bx_get('CmtQuote');
+        $bQuote = $bQuote !== false ? bx_process_input($bQuote, BX_DATA_INT) == 1 : false;
+
+        $bMinPostForm = bx_get('CmtMinPostForm');
+        $bMinPostForm = $bMinPostForm !== false ? bx_process_input($bMinPostForm, BX_DATA_INT) == 1 : $this->_bMinPostForm;
+
+        return $this->getFormBoxPost(array('parent_id' => $iCmtParentId, 'type' => $sCmtBrowse), array('type' => $sCmtDisplay, 'dynamic_mode' => true, 'quote' => $bQuote, 'min_post_form' => $bMinPostForm));
+    }
+
+    public function actionGetFormEdit ()
+    {
+        if (!$this->isEnabled())
+            return echoJson(array());
+
+        $iCmtId = bx_process_input(bx_get('Cmt'), BX_DATA_INT);
+        echoJson($this->getFormEdit($iCmtId, array('dynamic_mode' => true)));
+    }
+
+    public function actionGetCmt ()
+    {
+        if(!$this->isEnabled())
+            return '';
+
+        if($this->isViewAllowed() !== CHECK_ACTION_RESULT_ALLOWED)
+            return '';
+
+        $iCmtId = bx_process_input($_REQUEST['Cmt'], BX_DATA_INT);
+        $sCmtBrowse = isset($_REQUEST['CmtBrowse']) ? bx_process_input($_REQUEST['CmtBrowse'], BX_DATA_TEXT) : '';
+        $sCmtDisplay = isset($_REQUEST['CmtDisplay']) ? bx_process_input($_REQUEST['CmtDisplay'], BX_DATA_TEXT) : '';
+
+        $aCmt = $this->getCommentRow($iCmtId);
+        echoJson([
+            'parent_id' => $aCmt['cmt_parent_id'],
+            'vparent_id' => $aCmt['cmt_parent_id'],
+            'content' => $this->getComment($aCmt, ['type' => $sCmtBrowse], ['type' => $sCmtDisplay, 'dynamic_mode' => true])
+        ]);
+    }
+
+    public function actionGetCmts ()
+    {
+        if (!$this->isEnabled())
+            return '';
+
+        if($this->isViewAllowed() !== CHECK_ACTION_RESULT_ALLOWED)
+            return '';
+
+        $aBp = $aDp = array();
+        $this->_getParams($aBp, $aDp);
+
+        $aDp['dynamic_mode'] = true;
+
+        //--- Beg: Using pregenerated structure
+        if(isset($aDp['structure']) && $aDp['structure'] === true) {
+            $iCmtId = isset($aBp['parent_id']) ? (int)$aBp['parent_id'] : 0;
+
+            $mixedStructure = $this->getCommentStructure($iCmtId, $aBp, $aDp);
+            if($mixedStructure === false || empty($mixedStructure[$iCmtId]['items'])) 
+                return '';
+
+            $aDp['structure'] = $mixedStructure[$iCmtId]['items'];
+            return $this->getCommentsByStructure($aBp, $aDp);
+        }
+        //--- End: Using pregenerated structure
+        else
+            return $this->{'getComments' . ($aBp['pinned'] ? 'Pinned' : '')}($aBp, $aDp);
+    }
+
+    public function actionSubmitPostForm()
+    {
+        if(!$this->isEnabled())
+            return echoJson(array());
+
+        $iCmtParentId = 0;
+        if(bx_get('cmt_parent_id') !== false)
+            $iCmtParentId = bx_process_input(bx_get('cmt_parent_id'), BX_DATA_INT);
+
+        echoJson($this->getFormPost($iCmtParentId, array('dynamic_mode' => true)));
+    }
+
+    public function actionSubmitEditForm()
+    {
+        if (!$this->isEnabled()) {
+            echoJson(array());
+            return;
+        };
+
+        $iCmtId = 0;
+        if(bx_get('cmt_id') !== false)
+            $iCmtId = bx_process_input(bx_get('cmt_id'), BX_DATA_INT);
+
+        echoJson($this->getFormEdit($iCmtId, array('dynamic_mode' => true)));
+    }
+
+    public function actionRemove()
+    {
+        if (!$this->isEnabled())
+            return echoJson([]);
+
+        $iCmtId = 0;
+        if(bx_get('Cmt') !== false)
+            $iCmtId = bx_process_input(bx_get('Cmt'), BX_DATA_INT);
+
+        echoJson($this->remove($iCmtId));
+    }
+    
+    public function remove($iCmtId)
+    {
+        $aCmt = $this->_oQuery->getCommentsBy(array('type' => 'id', 'id' => $iCmtId));
+        if(!$aCmt)
+            return array('msg' => _t('_No such comment'));
+        
+        $iObjId = $this->getId();
+        if(!$iObjId){
+            $this->setId($aCmt['cmt_object_id']);
+            $iObjId = $aCmt['cmt_object_id'];
+        }
+        $iObjAthrId = $this->getObjectAuthorId($iObjId);
+        $iObjAthrPrivacyView = $this->getObjectPrivacyView($iObjId);
+
+        if($aCmt['cmt_replies'] > 0) {
+            if(!$this->isModerator())
+                return array('msg' => _t('_Can not delete comments with replies'));
+
+            $aReplies = $this->_oQuery->getCommentsBy(array('type' => 'parent_id', 'parent_id' => $iCmtId));
+            foreach($aReplies as $aReply)
+                $this->remove($aReply['cmt_id']);
+        }
+
+        $iPerformerId = $this->_getAuthorId();
+        if(!$this->isRemoveAllowed($aCmt))
+            return array('msg' => $aCmt['cmt_author_id'] == $iPerformerId ? strip_tags($this->msgErrRemoveAllowed()) : _t('_Access denied'));
+
+        $iCmtPrntId = (int)$aCmt['cmt_parent_id'];
+        if(!$this->_oQuery->removeComment($iObjId, $iCmtId, $iCmtPrntId)) 
+            return array('msg' => _t('_cmt_err_cannot_perform_action'));
+
+        $this->_triggerComment();
+
+        $oStorage = BxDolStorage::getObjectInstance($this->getStorageObjectName());
+
+        $aImages = $this->_oQuery->getFiles($this->_aSystem['system_id'], $iCmtId);
+        foreach($aImages as $aImage)
+            $oStorage->deleteFile($aImage['image_id']);
+
+        $this->_oQuery->deleteImages($this->_aSystem['system_id'], $iCmtId);
+
+        $this->isRemoveAllowed($aCmt, true);
+
+        $this->deleteVotes($iCmtId);
+
+        $this->deleteReactions($iCmtId);
+
+        $this->deleteScores($iCmtId);
+
+        $this->deleteReports($iCmtId);
+
+        $this->deleteMetaInfo($iCmtId);
+
+        $aAuditParams = $this->_prepareAuditParams($iCmtId, array('comment_author_id' => $aCmt['cmt_author_id'], 'comment_text' => $aCmt['cmt_text']));
+        bx_audit($iObjId, $this->_aSystem['module'], '_sys_audit_action_delete_comment', $aAuditParams);
+
+        $aAlertParams = $this->_prepareAlertParams($aCmt);
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_comment-commentRemoved '{object_name}', 'commentRemoved' - hook after a comment was removed
+         * - $unit_name - comment object name
+         * - $action - equals `commentRemoved`
+         * - $object_id - commented object id
+         * - $sender_id - profile id who performed the action
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `source` - [string] unique comment source string
+         *      - `object_system` - [string] comment object name
+         *      - `object_id` - [int] commented object id
+         *      - `object_author_id` - [int] commented object author profile id
+         *      - `comment_id` - [int] comment id unique in the comment object scope
+         *      - `comment_uniq_id` - [int] system wide unique comment id
+         *      - `comment_author_id` - [int] comment author profile id
+         *      - `comment_text` - [string] comment text
+         *      - `privacy_view` - [int] or [string] privacy for view comment action, @see BxDolPrivacy
+         *      - `cf` - [int] comment's audience filter value
+         * @hook @ref hook-bx_dol_comment-commentRemoved
+         */
+        bx_alert($this->_sSystem, 'commentRemoved', $iObjId, $iPerformerId, $aAlertParams);
+        
+        /**
+         * @hooks
+         * @hookdef hook-comment-deleted 'comment', 'deleted' - hook after a comment was removed
+         * It's equivalent to @ref hook-bx_dol_comment-commentRemoved
+         * except 'comment id' is provided in $object_id
+         * @hook @ref hook-comment-deleted
+         */
+        bx_alert('comment', 'deleted', $iCmtId, $iPerformerId, $aAlertParams);
+
+        if(!empty($iCmtPrntId)) {
+            $aCmtPrnt = $this->_oQuery->getCommentSimple($iObjId, $iCmtPrntId);
+            if(!empty($aCmtPrnt) && is_array($aCmtPrnt)) {
+                $aAlertParamsReply = $this->_prepareAlertParamsReply($aCmt, $aCmtPrnt);
+
+                /**
+                 * @hooks
+                 * @hookdef hook-bx_dol_comment-replyRemoved '{object_name}', 'replyRemoved' - hook after a reply was removed
+                 * - $unit_name - comment object name
+                 * - $action - equals `replyRemoved`
+                 * - $object_id - commented object id
+                 * - $sender_id - profile id who performed the action
+                 * - $extra_params - array of additional params with the following array keys:
+                 *      - `source` - [string] unique comment source string
+                 *      - `object_system` - [string] comment object name
+                 *      - `object_id` - [int] commented object id
+                 *      - `object_author_id` - [int] commented object author profile id
+                 *      - `object_author_id` - [int] commented object author profile id
+                 *      - `parent_id` - [int] parent comment id unique in the comment object scope
+                 *      - `parent_uniq_id` - [int] system wide unique parent comment id
+                 *      - `parent_author_id` - [int] parent comment author profile id
+                 *      - `comment_id` - [int] comment id unique in the comment object scope
+                 *      - `comment_uniq_id` - [int] system wide unique comment id
+                 *      - `comment_author_id` - [int] comment author profile id
+                 *      - `comment_text` - [string] comment text
+                 *      - `privacy_view` - [int] or [string] privacy for view comment action, @see BxDolPrivacy
+                 * @hook @ref hook-bx_dol_comment-replyRemoved
+                 */
+                bx_alert($this->_sSystem, 'replyRemoved', $iCmtPrntId, $iPerformerId, $aAlertParamsReply);
+                
+                /**
+                 * @hooks
+                 * @hookdef hook-reply-deleted 'reply', 'deleted' - hook after a reply was removed
+                 * It's equivalent to @ref hook-bx_dol_comment-replyRemoved
+                 * except 'comment id' is provided in $object_id
+                 * @hook @ref hook-reply-deleted
+                 */
+                bx_alert('reply', 'deleted', $iCmtId, $iPerformerId, $aAlertParamsReply);
+            }
+        }
+
+        $this->deleteUniqueIds($iCmtId);
+
+        $iCount = (int)$this->getCommentsCountAll(0, true);
+        $aResult = [
+            'id' => $iCmtId,
+            'count' => $iCount,
+            'countf' => $iCount > 0 ? $this->getCounter() : ''
+        ];
+
+        if(($oSockets = BxDolSockets::getInstance()) && $oSockets->isEnabled()) {
+            $oSockets->sendEvent($this->getSocketName(), $iObjId, 'comment_deleted', json_encode(array_merge($aResult, [
+                'author_id' => $iPerformerId
+            ])));
+
+            /**
+             * Send 'content edited' event.
+             * Note. 0 is used as $iContentId to allow to subscribe on all events at once.
+             */
+            $oSockets->sendEvent($this->_aSystem['module'], 0, 'edited', json_encode([
+                'id' => $iObjId,
+                'author_id' => $this->getObjectAuthorId(),
+                'cmt_author_id' => $aCmt['cmt_author_id'],
+                'peformer_id' => $iPerformerId
+            ]));
+        }
+
+        return $aResult;
+    }
+
+    public function add($aValues)
+    {
+        return $this->_getFormAdd($aValues);
+    }
+
+    public function actionResumeLiveUpdate()
+    {
+    	$sKey = $this->getNotificationId();
+
+    	bx_import('BxDolSession');
+    	BxDolSession::getInstance()->unsetValue($sKey);
+    }
+
+    public function actionPauseLiveUpdate()
+    {
+    	$sKey = $this->getNotificationId();
+
+    	bx_import('BxDolSession');
+    	BxDolSession::getInstance()->setValue($sKey, 1);
+    }
+
+    public function actionGetSiblingFiles()
+    {
+        $iFileId = 0;
+        if(bx_get('file_id') !== false)
+            $iFileId = bx_process_input(bx_get('file_id'), BX_DATA_INT);
+
+        if(!($aFileInfo = $this->_oQuery->getFileInfoById((int)$iFileId))) 
+            return echoJson(['error' => _t('_sys_txt_error_occured')]);
+
+        $aSiblings = [];
+        $aFiles = $this->_oQuery->getFiles($aFileInfo['system_id'], $aFileInfo['cmt_id']);
+        foreach($aFiles as $iIndex => $aFile) {
+            if($aFile['id'] != $iFileId)
+                continue;
+
+            $aSiblings['prev'] = isset($aFiles[$iIndex - 1]) ? $aFiles[$iIndex - 1] : false;
+            $aSiblings['next'] = isset($aFiles[$iIndex + 1]) ? $aFiles[$iIndex + 1] : false;
+        }
+
+        $oStorage = BxDolStorage::getObjectInstance($this->getStorageObjectName());
+        $oTranscoder = BxDolTranscoderImage::getObjectInstance($this->getTranscoderPreviewName());
+
+        foreach($aSiblings as $sSibling => $aSibling) {
+            if(!$aSibling)
+                continue;
+
+            $sFile = '';
+            if($oTranscoder && $oTranscoder->isMimeTypeSupported($aSibling['mime_type']))
+                $sFile = $oStorage->getFileUrlById($aSibling['image_id']);
+            else
+                $sFile = $this->_oTemplate->getIconUrl($oStorage->getIconNameByFileName($aFile['file_name']));
+
+            $aImageInfo = BxDolImageResize::getInstance()->getImageSize($sFile);
+
+            $iWidth = $iHeight = 0;
+            if(isset($aImageInfo['w']))
+                $iWidth = (int)$aImageInfo['w'];
+            if(isset($aImageInfo['h']))
+                $iHeight = (int)$aImageInfo['h'];
+
+            $aSiblings[$sSibling] = array_merge($aSibling, [
+                'file' => $sFile,
+                'file_id' => $aSibling['id'],
+                'w' => $iWidth, 
+                'h' => $iHeight
+            ]);
+        }
+
+        return echoJson($aSiblings);
+    }
+
+    public function onPostAfter($iCmtId, $aDp = [])
+    {
+        $iObjId = (int)$this->getId();
+
+        $aCmt = $this->_oQuery->getCommentSimple($iObjId, $iCmtId);
+        if(empty($aCmt) || !is_array($aCmt))
+            return false;
+
+        $iCmtPrntId = (int)$aCmt['cmt_parent_id'];
+        $iPerformerId = (int)$aCmt['cmt_author_id'];
+
+        $aAlertParams = $this->_prepareAlertParams($aCmt);
+        
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_comment-commentPost '{object_name}', 'commentPost' - hook after a comment was added
+         * - $unit_name - comment object name
+         * - $action - equals `commentPost`
+         * - $object_id - commented object id
+         * - $sender_id - profile id who performed the action
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `source` - [string] unique comment source string
+         *      - `object_system` - [string] comment object name
+         *      - `object_id` - [int] commented object id
+         *      - `object_author_id` - [int] commented object author profile id
+         *      - `comment_id` - [int] comment id unique in the comment object scope
+         *      - `comment_uniq_id` - [int] system wide unique comment id
+         *      - `comment_author_id` - [int] comment author profile id
+         *      - `comment_text` - [string] comment text
+         *      - `privacy_view` - [int] or [string] privacy for view comment action, @see BxDolPrivacy
+         *      - `cf` - [int] comment's audience filter value
+         * @hook @ref hook-bx_dol_comment-commentPost
+         */
+        bx_alert($this->_sSystem, 'commentPost', $iObjId, $iPerformerId, $aAlertParams);
+        
+        /**
+         * @hooks
+         * @hookdef hook-comment-added 'comment', 'added' - hook after a comment was added
+         * It's equivalent to @hook @ref hook-bx_dol_comment-commentPost
+         * except 'comment id' is provided in $object_id
+         * @hook @ref hook-comment-added
+         */
+        bx_alert('comment', 'added', $iCmtId, $iPerformerId, $aAlertParams);
+
+        $aAuditParams = $this->_prepareAuditParams($iCmtId, array('comment_author_id' => $aCmt['cmt_author_id'], 'comment_text' => $aCmt['cmt_text']));
+        bx_audit($iObjId, $this->_aSystem['module'], '_sys_audit_action_add_comment', $aAuditParams);
+
+        if(!empty($iCmtPrntId)) {
+            $aCmtPrnt = $this->_oQuery->getCommentSimple($iObjId, $iCmtPrntId);
+            if(!empty($aCmtPrnt) && is_array($aCmtPrnt)) {
+                $aAlertParamsReply = $this->_prepareAlertParamsReply($aCmt, $aCmtPrnt);
+                
+                /**
+                 * @hooks
+                 * @hookdef hook-bx_dol_comment-replyPost '{object_name}', 'replyPost' - hook after a reply was added
+                 * - $unit_name - comment object name
+                 * - $action - equals `replyPost`
+                 * - $object_id - parent comment id
+                 * - $sender_id - profile id who performed the action
+                 * - $extra_params - array of additional params with the following array keys:
+                 *      - `source` - [string] unique comment source string
+                 *      - `object_system` - [string] comment object name
+                 *      - `object_id` - [int] commented object id
+                 *      - `object_author_id` - [int] commented object author profile id
+                 *      - `object_author_id` - [int] commented object author profile id
+                 *      - `parent_id` - [int] parent comment id unique in the comment object scope
+                 *      - `parent_uniq_id` - [int] system wide unique parent comment id
+                 *      - `parent_author_id` - [int] parent comment author profile id
+                 *      - `comment_id` - [int] comment id unique in the comment object scope
+                 *      - `comment_uniq_id` - [int] system wide unique comment id
+                 *      - `comment_author_id` - [int] comment author profile id
+                 *      - `comment_text` - [string] comment text
+                 *      - `privacy_view` - [int] or [string] privacy for view comment action, @see BxDolPrivacy
+                 * @hook @ref hook-bx_dol_comment-replyPost
+                 */
+                bx_alert($this->_sSystem, 'replyPost', $iCmtPrntId, $iPerformerId, $aAlertParamsReply);
+                
+                /**
+                 * @hooks
+                 * @hookdef hook-comment-replied 'comment', 'replied' - hook after a reply was added
+                 * It's equivalent to @ref hook-bx_dol_comment-replyPost
+                 * except 'comment id' is provided in $object_id
+                 * @hook @ref hook-comment-replied
+                 */
+                bx_alert('comment', 'replied', $iCmtId, $iPerformerId, $aAlertParamsReply);
+            }
+        }
+
+        $iCount = (int)$this->getCommentsCountAll(0, true);
+        $aResult = [
+            'id' => $iCmtId, 
+            'parent_id' => $iCmtPrntId,
+            'count' => $iCount,
+            'countf' => $iCount > 0 ? $this->getCounter(['show_script' => false]) : ''
+        ];
+
+        if(($oSockets = BxDolSockets::getInstance()) && $oSockets->isEnabled()) {
+            $oSockets->sendEvent($this->getSocketName(), $iObjId, 'comment_added', json_encode(array_merge($aResult, [
+                'author_id' => $iPerformerId, 
+                'anchor' => $this->getItemAnchor($iCmtId)
+            ])));
+
+            /**
+             * Send 'content edited' event.
+             * Note. 0 is used as $iContentId to allow to subscribe on all events at once.
+             */
+            $oSockets->sendEvent($this->_aSystem['module'], 0, 'edited', json_encode([
+                'id' => $iObjId,
+            ]));
+        }
+
+        return $aResult;
+    }
+
+    public function onEditAfter($iCmtId, $aDp = [])
+    {
+    	$aCmt = $this->getCommentRow($iCmtId);
+        if(empty($aCmt) || !is_array($aCmt))
+            return false;
+
+        $aBp = [];
+        $this->_getParams($aBp, $aDp);
+
+        $iObjId = (int)$this->getId();
+        $iPerformerId = $this->_getAuthorId();
+
+        $aAlertParams = $this->_prepareAlertParams($aCmt);
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_comment-commentUpdated '{object_name}', 'commentUpdated' - hook after a comment was updated (edited)
+         * - $unit_name - comment object name
+         * - $action - equals `commentUpdated`
+         * - $object_id - commented object id
+         * - $sender_id - profile id who performed the action
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `source` - [string] unique comment source string
+         *      - `object_system` - [string] comment object name
+         *      - `object_id` - [int] commented object id
+         *      - `object_author_id` - [int] commented object author profile id
+         *      - `comment_id` - [int] comment id unique in the comment object scope
+         *      - `comment_uniq_id` - [int] system wide unique comment id
+         *      - `comment_author_id` - [int] comment author profile id
+         *      - `comment_text` - [string] comment text
+         *      - `privacy_view` - [int] or [string] privacy for view comment action, @see BxDolPrivacy
+         *      - `cf` - [int] comment's audience filter value
+         * @hook @ref hook-bx_dol_comment-commentUpdated
+         */
+        bx_alert($this->_sSystem, 'commentUpdated', $iObjId, $iPerformerId, $aAlertParams);
+        
+        /**
+         * @hooks
+         * @hookdef hook-comment-edited 'comment', 'edited' - hook after a comment was updated (edited)
+         * It's equivalent to @ref hook-bx_dol_comment-commentUpdated
+         * except 'comment id' is provided in $object_id
+         * @hook @ref hook-comment-edited
+         */
+        bx_alert('comment', 'edited', $iCmtId, $iPerformerId, $aAlertParams);
+
+        $aAuditParams = $this->_prepareAuditParams($iCmtId, ['comment_author_id' => $aCmt['cmt_author_id'], 'comment_text' => $aCmt['cmt_text']]);
+        bx_audit($iObjId, $this->_aSystem['module'], '_sys_audit_action_edit_comment', $aAuditParams);
+
+        $aResult = [
+            'id' => $iCmtId, 
+            'content' => $this->_getContent($aCmt, $aBp, $aDp)
+        ];
+
+        if(($oSockets = BxDolSockets::getInstance()) && $oSockets->isEnabled()) {
+            $oSockets->sendEvent($this->getSocketName(), $iObjId, 'comment_edited', json_encode(array_merge($aResult, [
+                'author_id' => $iPerformerId, 
+                'anchor' => $this->getItemAnchor($iCmtId)
+            ])));
+
+            /**
+             * Send 'content edited' event.
+             * Note. 0 is used as $iContentId to allow to subscribe on all events at once.
+             */
+            $oSockets->sendEvent($this->_aSystem['module'], 0, 'edited', json_encode([
+                'id' => $iObjId,
+            ]));
+        }
+
+        return $aResult;
+    }
+
+    public function serviceGetAuthor ($iContentId)
+    {
+        $aComment = $this->_oQuery->getCommentsBy(array('type' => 'id', 'id' => $iContentId));
+        $this->setId($aComment['cmt_object_id']);
+
+        return $aComment['cmt_author_id'];
+    }
+
+    public function serviceGetDateAdded ($iContentId)
+    {
+        $aComment = $this->_oQuery->getCommentsBy(array('type' => 'id', 'id' => $iContentId));
+        $this->setId($aComment['cmt_object_id']);
+
+        return $aComment['cmt_time'];
+    }
+
+    public function serviceGetDateChanged ($iContentId)
+    {
+        return 0;
+    }
+    public function serviceGetLink ($iContentId)
+    {
+        $aComment = $this->_oQuery->getCommentsBy(array('type' => 'id', 'id' => $iContentId));
+        $this->setId($aComment['cmt_object_id']);
+
+        return $this->getViewUrl($iContentId);
+    }
+
+    public function serviceGetTitle ($iContentId)
+    {
+        return '';
+    }
+
+    public function serviceGetText ($iContentId)
+    {
+        $aComment = $this->_oQuery->getCommentsBy(array('type' => 'id', 'id' => $iContentId));
+        $this->setId($aComment['cmt_object_id']);
+
+        return $aComment['cmt_text'];
+    }
+
+    public function serviceGetThumb ($iContentId)
+    {
+        return '';
+    }
+
+    public function serviceGetInfo ($iContentId, $bSearchableFieldsOnly = true)
+    {
+        $aComment = $this->_oQuery->getCommentsBy(array('type' => 'id', 'id' => $iContentId));
+        $this->setId($aComment['cmt_object_id']);
+
+        return BxDolContentInfo::formatFields($aComment);
+    }
+
+    public function serviceGetInfoApi ($iContentId, $bExtendedUnits = false)
+    {
+        $aData = $this->serviceGetInfo($iContentId, false);
+        if($aData)
+            $aData = $this->getDataAPI($aData, ['extended' => $bExtendedUnits]);
+
+        return $aData;
+    }
+
+    public function serviceGetSearchResultUnit ($iContentId, $sUnitTemplate = '')
+    {
+        $aComment = $this->_oQuery->getCommentsBy(array('type' => 'id', 'id' => $iContentId));
+        if(empty($aComment) || !is_array($aComment))
+            return '';
+
+        $this->setId($aComment['cmt_object_id']);
+
+        return $this->getComment($aComment, array(), array('type' => BX_CMT_DISPLAY_FLAT, 'view_only' => true));
+    }
+
+    public function serviceGetAll ($aParams = array())
+    {
+        if(empty($aParams) || !is_array($aParams))
+            $aParams = array('type' => 'all');
+
+        return $this->_oQuery->getCommentsBy($aParams);
+    }
+
+    public function serviceGetSearchableFieldsExtended($aInputsAdd = array())
+    {
+        $oForm = BxDolForm::getObjectInstance('sys_comment', 'sys_comment_post', $this->_oTemplate);
+        if(!$oForm)
+            return array();
+
+        $aSrchNamesExcept = array();
+        $aSrchCaptionsSystem = array(
+            'cmt_author_id' => '_sys_form_comment_input_caption_system_cmt_author_id',
+            'cmt_text' => '_sys_form_comment_input_caption_system_cmt_text'
+        );
+        $aSrchCaptions = array(
+            'cmt_author_id' => '_sys_form_comment_input_caption_cmt_author_id',
+            'cmt_text' => '_sys_form_comment_input_caption_cmt_text'
+        );
+
+        $aResult = array(
+            'cmt_author_id' => array(
+                'type' => 'text_auto', 
+                'caption_system' => $aSrchCaptionsSystem['cmt_author_id'],
+                'caption' => $aSrchCaptions['cmt_author_id'],
+                'info' => '',
+                'value' => '',
+                'values' => '',
+                'pass' => ''
+            )
+        );
+
+        $aInputs = $oForm->aInputs;
+        if(!empty($aInputsAdd) && is_array($aInputsAdd))
+            $aInputs = array_merge($aInputs, $aInputsAdd);
+
+        foreach($aInputs as $aInput)
+            if(in_array($aInput['type'], BxDolSearchExtended::$SEARCHABLE_TYPES) && !in_array($aInput['name'], $aSrchNamesExcept))
+                $aResult[$aInput['name']] = array(
+                    'type' => $aInput['type'], 
+                    'caption_system' => !empty($aInput['caption_system_src']) ? $aInput['caption_system_src'] : '',
+                    'caption' => !empty($aInput['caption_src']) ? $aInput['caption_src'] : (!empty($aSrchCaptions[$aInput['name']]) ? $aSrchCaptions[$aInput['name']] : ''),
+                    'info' => !empty($aInput['info_src']) ? $aInput['info_src'] : '',
+                    'value' => !empty($aInput['value']) ? $aInput['value'] : '',
+                    'values' => !empty($aInput['values_src']) ? $aInput['values_src'] : '',
+                    'pass' => !empty($aInput['db']['pass']) ? $aInput['db']['pass'] : ''
+                );
+
+        return $aResult;
+    }
+
+    /**
+     * Overwrite this method and register transcoder(s) if comments object uses custom transcoder(s), 
+     * which differs from default one 'sys_cmts_images_preview'
+     */
+    public function registerTranscoders()
+    {}
+
+	/**
+     * Overwrite this method and unregister transcoder(s) if comments object uses custom transcoder(s), 
+     * which differs from default one 'sys_cmts_images_preview'
+     */
+    public function unregisterTranscoders()
+    {}
+
+    public function serviceGetSearchResultExtended($aParams, $iStart = 0, $iPerPage = 0, $bFilterMode = false)
+    {
+        if((empty($aParams) || !is_array($aParams)) && !$bFilterMode)
+            return array();
+
+        return $this->_oQuery->getCommentsBy(array('type' => 'search_ids', 'search_params' => $aParams, 'start' => $iStart, 'per_page' => $iPerPage));
+    }
+
+    public function getAuthorInfo($iAuthorId = 0)
+    {
+        return $this->_getAuthorInfo($iAuthorId);
+    }
+    
+    public function getParams(&$aBp, &$aDp)
+    {
+        return $this->_getParams($aBp, $aDp);
+    }
+    
+    public function prepareParams(&$aBp, &$aDp)
+    {
+         return $this->_prepareParams($aBp, $aDp);
+    }
+
+    /**
+     * Internal functions
+     */
+    protected function _getAuthorId ()
+    {
+        return isMember() ? bx_get_logged_profile_id() : 0;
+    }
+
+    protected function _getAuthorPassword ()
+    {
+        return getLoggedPassword();
+    }
+
+    protected function _getAuthorIp ()
+    {
+        return getVisitorIP();
+    }
+
+    protected function _getAuthorInfo($iAuthorId = 0)
+    {
+        $iUserId = $this->_getAuthorId();
+        $iAuthorId = (int)$iAuthorId;
+        $oProfile = $this->_getAuthorObject($iAuthorId);
+        if (!$oProfile->isActive() && !isAdmin() && !BxDolAcl::getInstance()->isMemberLevelInSet(array(MEMBERSHIP_ID_MODERATOR, MEMBERSHIP_ID_ADMINISTRATOR)))
+            $oProfile = BxDolProfileUndefined::getInstance();
+
+        return array(
+            $oProfile->getDisplayName(),
+            $oProfile->getUrl(),
+            $oProfile->getThumb(),
+            $oProfile->getUnit(0, array('template' => 'unit_wo_info')),
+            $oProfile->getBadges()
+        );
+    }
+
+    protected function _getAuthorObject($iAuthorId = 0)
+    {
+        return BxDolProfile::getInstanceMagic((int)$iAuthorId);
+    }
+
+    protected function _getFormObject($sAction = BX_CMT_ACTION_POST)
+    {
+        $sDisplayName = '_sFormDisplay' . ucfirst($sAction);
+
+        return BxDolForm::getObjectInstance($this->_sFormObject, $this->$sDisplayName, $this->_oTemplate, $this->_sSystem);
+    }
+    
+    protected function _unsetFormObject($sAction = BX_CMT_ACTION_POST)
+    {
+        $sDisplayName = '_sFormDisplay' . ucfirst($sAction);
+
+        return BxDolForm::unsetObjectInstance($this->_sFormObject, $this->$sDisplayName, $this->_oTemplate, $this->_sSystem);
+    }
+
+    protected function _getParams(&$aBp, &$aDp)
+    {
+        //--- Process 'Browse' params. 
+        $aBp['parent_id'] = isset($aBp['parent_id']) ? (int)$aBp['parent_id'] : 0;
+
+        $aBp['vparent_id'] = isset($aBp['vparent_id']) ? (int)$aBp['vparent_id'] : 0;
+        if(bx_get('CmtParent') !== false)
+            $aBp['vparent_id'] = bx_process_input(bx_get('CmtParent'), BX_DATA_INT);
+
+    	$aBp['type'] = isset($aBp['type']) ? $aBp['type'] : '';
+    	if(bx_get('CmtBrowse') !== false) 
+        	$aBp['type'] = bx_process_input(bx_get('CmtBrowse'), BX_DATA_TEXT);
+
+    	$aBp['filter'] = isset($aBp['filter']) ? $aBp['filter'] : '';
+    	if(bx_get('CmtFilter') !== false) 
+    	    $aBp['filter'] = bx_process_input(bx_get('CmtFilter'), BX_DATA_TEXT);
+
+        $aBp['start'] = isset($aBp['start']) ? (int)$aBp['start'] : -1;
+        if(bx_get('CmtStart') !== false) 
+            $aBp['start'] = bx_process_input($_REQUEST['CmtStart'], BX_DATA_INT);
+
+        $aBp['per_view'] = isset($aBp['per_view']) ? (int)$aBp['per_view'] : -1;
+        if(bx_get('CmtPerView') !== false) 
+            $aBp['per_view'] = bx_process_input($_REQUEST['CmtPerView'], BX_DATA_INT);
+
+        $aBp['pinned'] = isset($aBp['pinned']) ? (int)$aBp['pinned'] : 0;
+        if(bx_get('CmtPinned') !== false) 
+            $aBp['pinned'] = bx_process_input(bx_get('CmtPinned'), BX_DATA_INT);
+
+        //--- Process 'Display' params.
+        $aDp['type'] = isset($aDp['type']) ? $aDp['type'] : '';
+        if(bx_get('CmtDisplay') !== false) 
+            $aDp['type'] = bx_process_input($_REQUEST['CmtDisplay'], BX_DATA_TEXT);
+
+        if(bx_get('CmtDisplayStructure') !== false) {
+            $aDp['structure'] = bx_process_input($_REQUEST['CmtDisplayStructure'], BX_DATA_INT) == 1;
+
+            if($aDp['structure'] && bx_get('CmtParent') !== false)
+                $aBp['parent_id'] = bx_process_input(bx_get('CmtParent'), BX_DATA_INT);
+        }
+
+        $aDp['blink'] = isset($aDp['blink']) ? $aDp['blink'] : '';
+        if(bx_get('CmtBlink') !== false) 
+            $aDp['blink'] = bx_process_input($_REQUEST['CmtBlink'], BX_DATA_TEXT);
+
+        $aDp['quote'] = isset($aDp['quote']) ? (int)$aDp['quote'] : 0;
+        if(bx_get('CmtQuote') !== false) 
+            $aDp['quote'] = bx_process_input($_REQUEST['CmtQuote'], BX_DATA_INT);
+
+        $aDp['min_post_form'] = isset($aDp['min_post_form']) ? (bool)$aDp['min_post_form'] : $this->_bMinPostForm;
+        if(bx_get('CmtMinPostForm') !== false)
+            $aDp['min_post_form'] = bx_process_input(bx_get('CmtMinPostForm'), BX_DATA_INT) == 1;
+
+        $aDp['in_designbox'] = isset($aDp['in_designbox']) ? (bool)$aDp['in_designbox'] : true;
+        $aDp['dynamic_mode'] = isset($aDp['dynamic_mode']) ? (bool)$aDp['dynamic_mode'] : false;
+        $aDp['show_empty'] = isset($aDp['show_empty']) ? (bool)$aDp['show_empty'] : false;
+    }
+
+    protected function _prepareAlertParams($aCmt)
+    {
+        $iObjId = (int)$this->getId();
+        $iObjAthrId = $this->getObjectAuthorId($iObjId);
+        $iObjAthrPrivacyView = $this->getObjectPrivacyView($iObjId);
+
+        $iCmtId = (int)$aCmt['cmt_id'];
+        $iCmtUniqId = $this->getCommentUniqId($iCmtId);
+        $iCmtCf = isset($aCmt['cmt_cf']) ? (int)$aCmt['cmt_cf'] : BxDolContentFilter::getInstance()->getDefaultValue();
+
+        return array(
+            'source' => 'sys_cmts_' . $iCmtUniqId,
+
+            'object_system' => $this->_sSystem, 
+            'object_id' => $iObjId, 
+            'object_author_id' => $iObjAthrId,
+
+            'comment_id' => $iCmtId, 
+            'comment_uniq_id' => $iCmtUniqId,
+            'comment_author_id' => $aCmt['cmt_author_id'], 
+            'comment_parent_id' => $aCmt['cmt_parent_id'], 
+            'comment_text' => $aCmt['cmt_text'],
+
+            'privacy_view' => $iObjAthrPrivacyView,
+            'cf' => $iCmtCf
+        );
+    }
+
+    protected function _prepareAlertParamsReply($aCmt, $aCmtPrnt)
+    {
+        $iObjId = (int)$this->getId();
+        $iObjAthrId = $this->getObjectAuthorId($iObjId);
+        $iObjAthrPrivacyView = $this->getObjectPrivacyView($iObjId);
+
+        $iCmtPrntId = (int)$aCmt['cmt_parent_id'];
+        $iCmtPrntUniqId = $this->getCommentUniqId($iCmtPrntId);
+
+        $iCmtId = (int)$aCmt['cmt_id'];
+        $iCmtUniqId = $this->getCommentUniqId($iCmtId);
+
+        return array(
+            'source' => 'sys_cmts_' . $iCmtUniqId,
+
+            'object_system' => $this->_sSystem, 
+            'object_id' => $iObjId, 
+            'object_author_id' => $iObjAthrId,
+
+            'parent_id' => $iCmtPrntId,
+            'parent_uniq_id' => $iCmtPrntUniqId,
+            'parent_author_id' => $aCmtPrnt['cmt_author_id'],
+
+            'comment_id' => $iCmtId,
+            'comment_uniq_id' => $iCmtUniqId,
+            'comment_author_id' => $aCmt['cmt_author_id'],  
+            'comment_text' => $aCmt['cmt_text'],
+
+            'privacy_view' => $iObjAthrPrivacyView,
+        );
+    }
+
+    protected function _prepareAuditParams($iId, $aData)
+    {
+        $sModule = $this->_aSystem['module'];
+        $oModule = BxDolModule::getInstance($sModule);
+        $CNF = isset($oModule->_oConfig->CNF) ? $oModule->_oConfig->CNF : array();
+
+        $aContentInfo = BxDolRequest::serviceExists($sModule, 'get_all') ? BxDolService::call($sModule, 'get_all', array(array('type' => 'id', 'id' => $this->getId()))) : array();
+        $iContextId = 0;
+        if (!empty($aContentInfo)){
+            $iContextId = isset($CNF['FIELD_ALLOW_VIEW_TO']) && (!empty($aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']]) && (int)$aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']] < 0) ? - $aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']] : 0; 
+        }
+        
+        $AuditParams = array(
+            'content_title' => $this->getObjectTitle() ,
+            'context_profile_id' => $iContextId,
+            'content_info_object' =>  isset($CNF['OBJECT_CMTS_CONTENT_INFO']) ? $CNF['OBJECT_CMTS_CONTENT_INFO'] : '',
+            'data' => array_merge(array('comment_id' => $iId), $aData)
+        );
+        if ($iContextId > 0)
+            $AuditParams['context_profile_title'] = BxDolProfile::getInstance($iContextId)->getDisplayName();
+        
+        return $AuditParams;
+    }
+
+    protected function _prepareTextForOutput ($s, $iCmtId = 0)
+    {
+    	$iDataAction = !$this->isHtml() ? BX_DATA_TEXT_MULTILINE : BX_DATA_HTML;
+    	$s = bx_process_output($s, $iDataAction);
+    	$s = bx_linkify_html($s, 'class="' . BX_DOL_LINK_CLASS . '"');
+
+        if($iCmtId && $this->_sMetatagsObj && ($oMetatags = BxDolMetatags::getObjectInstance($this->_sMetatagsObj)) !== false)
+            $s = $oMetatags->metaParse($this->_oQuery->getUniqId($this->_aSystem['system_id'], $iCmtId), $s);
+
+        return $s;
+    }
+
+    protected function _prepareStructureBp($sDType, &$aBp)
+    {
+        $aBp['type'] = !empty($aBp['type']) ? $aBp['type'] : $this->_sBrowseType;
+        $aBp['filter'] = !empty($aBp['filter']) ? $aBp['filter'] : $this->_sBrowseFilter;
+        $aBp['parent_id'] = isset($aBp['parent_id']) ? $aBp['parent_id'] : 0;
+        $aBp['start'] = isset($aBp['start']) ? $aBp['start'] : -1;
+        $aBp['init_view'] = isset($aBp['init_view']) ? $aBp['init_view'] : -1;
+        $aBp['per_view'] = isset($aBp['per_view']) ? $aBp['per_view'] : -1;
+        $aBp['order']['by'] = isset($aBp['order_by']) ? $aBp['order_by'] : $this->_aOrder['by'];
+        $aBp['order']['way'] = isset($aBp['order_way']) ? $aBp['order_way'] : $this->_aOrder['way'];
+
+        if($aBp['per_view'] == -1)
+            switch($sDType) {
+                case BX_CMT_DISPLAY_FLAT:
+                    $aBp['per_view'] = $this->getPerView(0);
+                    break;
+
+                case BX_CMT_DISPLAY_THREADED:
+                    $aBp['per_view'] = $this->getPerView($aBp['parent_id']);
+                    break;
+            }
+    }
+
+    protected function _prepareParams(&$aBp, &$aDp)
+    {
+        $aBp['type'] = !empty($aBp['type']) ? $aBp['type'] : $this->_sBrowseType;
+        $aBp['filter'] = !empty($aBp['filter']) ? $aBp['filter'] : $this->_sBrowseFilter;
+        $aBp['parent_id'] = isset($aBp['parent_id']) ? $aBp['parent_id'] : 0;
+        $aBp['start'] = isset($aBp['start']) ? $aBp['start'] : -1;
+        $aBp['init_view'] = isset($aBp['init_view']) ? $aBp['init_view'] : -1;
+        $aBp['per_view'] = isset($aBp['per_view']) ? $aBp['per_view'] : -1;
+        $aBp['pinned'] = isset($aBp['pinned']) ? (int)$aBp['pinned'] : 0;
+        $aBp['order']['by'] = isset($aBp['order_by']) ? $aBp['order_by'] : $this->_aOrder['by'];
+        $aBp['order']['way'] = isset($aBp['order_way']) ? $aBp['order_way'] : $this->_aOrder['way'];
+
+        $aDp['type'] = !empty($aDp['type']) ? $aDp['type'] : $this->_sDisplayType;
+        $aDp['blink'] = !empty($aDp['blink']) ? $aDp['blink'] : array();
+        if(!is_array($aDp['blink']))
+            $aDp['blink'] = explode(',', $aDp['blink']);
+        $aDp['quote'] = !empty($aDp['quote']) ? (int)$aDp['quote'] : 0;
+        if(!isset($aDp['min_post_form']))
+            $aDp['min_post_form'] = $this->_bMinPostForm;
+
+        switch($aDp['type']) {
+            case BX_CMT_DISPLAY_FLAT:
+                $aBp['vparent_id'] = -1;
+                $aBp['per_view'] = $aBp['per_view'] != -1 ? $aBp['per_view'] : $this->getPerView(0);
+                break;
+
+            case BX_CMT_DISPLAY_THREADED:
+                $iParent = 0;
+                if(isset($aBp['vparent_id']))
+                    $iParent = $aBp['vparent_id'];
+                else if(isset($aBp['parent_id']))
+                    $iParent = $aBp['parent_id'];
+
+                $aBp['per_view'] = $aBp['per_view'] != -1 ? $aBp['per_view'] : $this->getPerView($iParent);
+                break;
+        }
+
+        switch ($aBp['type']) {
+            case BX_CMT_BROWSE_POPULAR:
+                $aBp['order'] = array(
+                    'by' => BX_CMT_ORDER_BY_POPULAR,
+                    'way' => BX_CMT_ORDER_WAY_DESC
+                );
+                break;
+        }
+
+        if(!isset($aBp['count']))
+            $aBp['count'] = $this->getCommentsCount($this->_iId, $aBp['vparent_id'], $aBp['filter']);
+
+        if($aBp['start'] != -1)
+            return;
+
+        $aBp['start'] = 0;
+        if($aBp['type'] == BX_CMT_BROWSE_TAIL) {
+            $sPerView = ($aBp['init_view'] != -1 ? 'init' : 'per') . '_view';
+
+            $aBp['start'] = $aBp['count'] - $aBp[$sPerView];
+            if($aBp['start'] < 0) {
+                $aBp[$sPerView] += $aBp['start'];
+                $aBp['start'] = 0;
+            }
+        }
+
+        $this->_setUserChoice($aDp['type'], $aBp['type'], $aBp['filter']);
+    }
+
+    protected function _triggerComment()
+    {
+        if(!$this->_aSystem['trigger_table'] || !$this->_aSystem['trigger_field_id'] || !$this->_aSystem['trigger_field_comments'])
+            return false;
+
+        $iId = $this->getId();
+        if(!$iId)
+            return false;
+
+        $iCount = $this->getCommentsCount($iId);
+        return $this->_oQuery->updateTriggerTable($iId, $iCount);
+    }
+
+    /**
+     * Replace provided markers in a string
+     * @param $mixed string or array to replace markers in
+     * @return string where all occured markers are replaced
+     */
+    protected function _replaceMarkers ($mixed)
+    {
+        return bx_replace_markers($mixed, $this->_aMarkers);
+    }
+
+    protected function _getUserChoice()
+    {
+        $mixedDp = $mixedBpType = $mixedBpFilter = false;
+        if(!isLogged())
+            return array($mixedDp, $mixedBpType, $mixedBpFilter);
+
+        $iUserId = $this->_getAuthorId();
+
+        $oSession = BxDolSession::getInstance();
+
+        $mixedDp = $oSession->getValue($this->_sDpSessionKey . $iUserId);
+        $mixedBpType = $oSession->getValue($this->_sBpSessionKeyType . $iUserId);
+        $mixedBpFilter = $oSession->getValue($this->_sBpSessionKeyFilter . $iUserId);
+
+        return array($mixedDp, $mixedBpType, $mixedBpFilter);
+    }
+
+    protected function _setUserChoice($sDp, $sBpType, $sBpFilter)
+    {
+        if(!isLogged())
+            return;
+
+        $iUserId = $this->_getAuthorId();
+
+        $oSession = BxDolSession::getInstance();
+
+        if(!empty($sDp))
+            $oSession->setValue($this->_sDpSessionKey . $iUserId, $sDp);
+
+        if(!empty($sBpType))
+            $oSession->setValue($this->_sBpSessionKeyType . $iUserId, $sBpType);
+
+        if(!empty($sBpFilter))
+            $oSession->setValue($this->_sBpSessionKeyFilter . $iUserId, $sBpFilter);
+    }
+
+    protected function _sendNotificationEmail($iCmtId, $iCmtParentId)
+    {
+        $aCmt = $this->getCommentRow($iCmtId);
+        $aCmtParent = $this->getCommentRow($iCmtParentId);
+        if(empty($aCmt) || !is_array($aCmt) || empty($aCmtParent) || !is_array($aCmtParent) || (int)$aCmt['cmt_author_id'] == (int)$aCmtParent['cmt_author_id'])
+            return;
+
+        $oProfile = $this->_getAuthorObject($aCmtParent['cmt_author_id']);
+
+        if($oProfile instanceof BxDolProfileUndefined)
+        	return;
+
+        $iAccount = $oProfile->getAccountId();
+        $aAccount = BxDolAccount::getInstance($iAccount)->getInfo();
+
+        $aPlus = array();
+        $aPlus['sender_display_name'] = $oProfile->getDisplayName();
+        $aPlus['reply_text'] = $this->_prepareTextForOutput($aCmt['cmt_text'], $iCmtId);
+
+        $sPageUrl = $this->getBaseUrl();
+        if(empty($sPageUrl))
+            $sPageUrl = $this->getViewUrl($iCmtParentId);
+        else 
+            $sPageUrl .= $this->getItemAnchor($iCmtParentId, true);
+        $aPlus['page_url'] = $sPageUrl;
+
+        $sPageTitle = $this->getObjectTitle();
+        if(empty($sPageTitle))
+            $sPageTitle = _t('_Content');
+        $aPlus['page_title'] = $sPageTitle;
+
+        $aTemplate = BxDolEmailTemplates::getInstance()->parseTemplate('t_CommentReplied', $aPlus);
+        return $aTemplate && sendMail($aAccount['email'], $aTemplate['Subject'], $aTemplate['Body']);
+    }
+
+    protected function _isShowDoComment($aParams, $isAllowedComment, $bCount)
+    {
+        $bShowDoComment = !isset($aParams['show_do_comment']) || $aParams['show_do_comment'] == true;
+
+        return $bShowDoComment && ($isAllowedComment || $bCount);
+    }
+
+    protected function _isShowCounter($aParams, $isAllowedComment, $bCount)
+    {
+        $bShowCounter = isset($aParams['show_counter']) && $aParams['show_counter'] === true;
+
+        return $bShowCounter && ($isAllowedComment || $bCount);
+    }
+
+    /**
+     * Note. By default image based controls aren't used.
+     * Therefore it can be overwritten in custom template.
+     */
+    protected function _getImageDo()
+    {
+    	return '';
+    }
+
+    protected function _getIconDo()
+    {
+    	return 'far comment';
+    }
+
+    protected function _getTitleDo()
+    {
+    	return '_cmt_txt_do';
+    }
+
+    protected function _getParents($aCmt, $iDepthMax, &$iDepth, &$aParents)
+    {
+        $iParentId = (int)$aCmt['cmt_parent_id'];
+        if($iParentId == 0)
+            return;
+
+        $aParents[] = $iParentId;
+        $iDepth++;
+
+        if($iDepthMax != 0 && $iDepth == $iDepthMax)
+            return;
+
+        $aCmt = $this->_oQuery->getCommentsBy(['type' => 'id', 'id' => $iParentId]);
+        $this->_getParents($aCmt, $iDepthMax, $iDepth, $aParents);
+    }
+
+    public function _getStructure($mixedItem, $aBp, &$iLevel, &$aStructure)
+    {
+        $bItem = !empty($mixedItem) && is_array($mixedItem);
+
+        $iCmtId = $bItem ? (int)$mixedItem['cmt_id'] : 0;
+        $iCmtIndex = $iCmtId;
+
+        if($bItem)
+            $aStructure[$iCmtIndex] = [
+                'id' => $mixedItem['cmt_id'], 
+                'order' => isset($mixedItem['cmt_order']) ? $mixedItem['cmt_order'] : 0, 
+                'items' => [],
+            ];
+
+        if(!$bItem || (int)$mixedItem['cmt_replies'] > 0) {
+            $aItems = $this->_oQuery->getStructure($this->_iId, $this->_iAuthorId, $iCmtId, $aBp['filter'], $aBp['order']);
+            if(!empty($aItems)) {
+                if($bItem && $iLevel < (int)$this->_aSystem['number_of_levels']) {
+                    $iPassLevel = $iLevel + 1;
+                    $aPassStructure = &$aStructure[$iCmtIndex]['items'];
+                }
+                else {
+                    $iPassLevel = $iLevel;
+                    $aPassStructure = &$aStructure; //--- Is NEEDED here to correctly check sublevels.
+                }
+
+                foreach($aItems as $aItem)
+                    $this->_getStructure($aItem, $aBp, $iPassLevel, $aPassStructure);
+
+                //--- Sort subitems
+                $iWay = isset($aBp['order']['way']) && $aBp['order']['way'] == 'desc' ? -1 : 1;
+                uasort($aPassStructure, function($aItem1, $aItem2) use ($iWay) {
+                    if($aItem1['order'] == $aItem2['order'])
+                        return 0;
+
+                    return $iWay * ($aItem1['order'] < $aItem2['order'] ? -1 : 1);
+                });
+            }
+        }
+    }
+
+    public function _getStructureAPI($mixedItem, $aBp, &$iLevel, &$aStructure)
+    {
+        $bItem = !empty($mixedItem) && is_array($mixedItem);
+
+        $iCmtId = $bItem ? (int)$mixedItem['cmt_id'] : 0;
+        $sCmtIndex = 'i' . $iCmtId;
+
+        if($bItem) {
+            $oMenuActions = BxDolMenu::getObjectInstance($this->_sMenuObjActions);
+            $oMenuActions->setCmtsData($this, $iCmtId, $aBp);
+
+            $oMenuManage = BxDolMenu::getObjectInstance($this->_sMenuObjManage);
+            $oMenuManage->setCmtsData($this, $iCmtId, $aBp);
+
+            $aData = $this->getCommentSimple($iCmtId);
+            
+            bx_import('BxDolEmbed');
+            $aData['embed'] = bx_linkify_embeded($aData['cmt_text']);
+            
+            $aData = array_merge($this->getDataAPI($aData), [
+                'menu_actions' => $oMenuActions->getCodeAPI(),
+                'menu_manage' => $oMenuManage->getShortCodeAPI(),
+            ]);
+
+            $aStructure[$sCmtIndex] = [
+                'id' => $iCmtId, 
+                'order' => isset($mixedItem['cmt_order']) ? $mixedItem['cmt_order'] : 0, 
+                'data' => $aData,
+                'files' => $this->_getAttachments($mixedItem),
+                'items' => [],
+            ];
+        }
+
+        if(!$bItem || (int)$mixedItem['cmt_replies'] > 0) {
+            $aItems = $this->_oQuery->getStructure($this->_iId, $this->_iAuthorId, $iCmtId, $aBp['filter'], $aBp['order']);
+            if(!empty($aItems)) {
+                if($bItem && $iLevel < (int)$this->_aSystem['number_of_levels']) {
+                    $iPassLevel = $iLevel + 1;
+                    $aPassStructure = &$aStructure[$sCmtIndex]['items'];
+                }
+                else {
+                    $iPassLevel = $iLevel;
+                    $aPassStructure = [];
+                }
+                
+                foreach($aItems as $aItem)
+                    $this->_getStructureAPI($aItem, $aBp, $iPassLevel, $aPassStructure);
+                
+                //--- Sort subitems
+                $iWay = isset($aBp['order']['way']) && $aBp['order']['way'] == 'desc' ? -1 : 1;
+
+                uasort($aPassStructure, function($aItem1, $aItem2) use ($iWay) {
+                    if($aItem1['order'] == $aItem2['order'])
+                        return 0;
+
+                    return $iWay * ($aItem1['order'] < $aItem2['order'] ? -1 : 1);
+                });
+
+                $aStructure[$sCmtIndex]['items'] = $aPassStructure;
+            }
+        }
+    }
+
+    public function getDataAPI($aData, $aParams = [])
+    {
+        $aDataApi = array_merge($aData, [
+            'cmt_url' => '/' . $this->getViewUrl($aData['cmt_id'], false),
+            'author_data' => BxDolProfile::getData($aData['cmt_author_id']),
+        ]);
+
+        $sModule = $this->_aSystem['module'];
+        $aExtras = [
+            'module' => $sModule,
+            'data' => $aData,
+            'params' => $aParams,
+            'data_api' => &$aDataApi,
+        ];
+
+        /**
+         * @hooks
+         * @hookdef hook-system-decode_comment_data_api 'system', 'decode_comment_data_api' - hook to override comment data prepared for sending in API response
+         * - $unit_name - equals `system`
+         * - $action - equals `decode_comment_data_api`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `module` - [string] module name
+         *      - `data` - [array] comment info array as key&value pairs
+         *      - `params` - [array] params array as key&value pairs
+         *      - `data_api` - [array] by ref, comment data prepared for sending in API response, can be overridden in hook processing
+         * @hook @ref hook-system-decode_comment_data_api
+         */
+        bx_alert('system', 'decode_comment_data_api', 0, 0, $aExtras);
+        
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_comment-decode_comment_data_api '{object_name}', 'decode_comment_data_api' - hook to override comment data prepared for sending in API response
+         * It's equivalent to @ref hook-system-decode_comment_data_api
+         * @hook @ref hook-bx_dol_comment-decode_comment_data_api
+         */
+        bx_alert($sModule, 'decode_comment_data_api', 0, 0, $aExtras);
+
+        return $aDataApi;
+    }
+}
+
+/** @} */

+ 1356 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolDb.php

@@ -0,0 +1,1356 @@
+<?php defined('BX_DOL') or defined('BX_DOL_INSTALL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+define('BX_DB_MODE_SILENT', PDO::ERRMODE_SILENT);
+define('BX_DB_MODE_EXCEPTION', PDO::ERRMODE_EXCEPTION);
+
+define('BX_DB_ERR_CONNECT_FAILD', 1);
+define('BX_DB_ERR_QUERY_ERROR', 2);
+define('BX_DB_ERR_ESCAPE', 3);
+
+define('BX_PDO_STATE_NOT_EXECUTED', NULL);
+define('BX_PDO_STATE_SUCCESS', '00000');
+
+class BxDolDb extends BxDolFactory implements iBxDolSingleton
+{	
+    protected static $_rLink;
+    protected static $_aDbCacheData = [];
+
+    protected static $_aParams;
+    protected static $_sParamsCacheName = 'sys_options';
+    protected static $_sParamsCacheNameMixed = 'sys_options_mixed_';
+    protected static $_sParamsCacheNameMix = 'sys_options_mix_';
+
+    protected static $_sErrorKey = 'bx_db_error';
+    protected static $_aErrors = array(
+    	BX_DB_ERR_CONNECT_FAILD => 'Database connect failed',
+    	BX_DB_ERR_QUERY_ERROR => 'Database query error',
+    	BX_DB_ERR_ESCAPE => 'Escape string error'
+    );
+
+    protected static $_bReadOnlyMode = true;
+    protected static $_bMultuServersMode = false;
+
+	protected $_bPdoPersistent;
+	protected $_iPdoFetchType;
+	protected $_iPdoErrorMode;
+
+	protected $_bErrorChecking;
+    protected $_aError;
+
+	protected $_sHost, $_sPort, $_sSocket, $_sDbname, $_sUser, $_sPassword, $_sCharset, $_sStorageEngine;
+
+    protected $_oStatement = null;
+    protected $_oDbCacheObject = null;
+
+    /**
+     * set database parameters and connect to it
+     */
+    protected function __construct($aDbConf = false)
+    {
+        if (isset($GLOBALS['bxDolClasses'][get_class($this)]))
+            trigger_error ('Multiple instances are not allowed for the class: ' . get_class($this), E_USER_ERROR);
+
+        parent::__construct();
+
+		$this->_bPdoPersistent = true;
+        if (defined('BX_DATABASE_PERSISTENT'))
+            $this->_bPdoPersistent = (bool)BX_DATABASE_PERSISTENT;
+
+        $this->_iPdoFetchType = PDO::FETCH_ASSOC;
+        $this->_iPdoErrorMode = BX_DB_MODE_EXCEPTION;
+
+        $this->_bErrorChecking = true;
+        $this->_aError = array();
+
+        $this->_sStorageEngine = 'MYISAM';
+        if (defined('BX_DATABASE_ENGINE'))
+            $this->_sStorageEngine = BX_DATABASE_ENGINE;
+
+        $this->_sCharset = 'utf8mb4';
+        if($aDbConf === false) {
+            $this->_sHost = BX_DATABASE_HOST;
+            $this->_sPort = BX_DATABASE_PORT;
+            $this->_sSocket = BX_DATABASE_SOCK;
+            $this->_sDbname = BX_DATABASE_NAME;
+            $this->_sUser = BX_DATABASE_USER;
+            $this->_sPassword = BX_DATABASE_PASS;
+        } 
+        else {
+            $this->_sHost = $aDbConf['host'];
+            $this->_sPort = $aDbConf['port'];
+            $this->_sSocket = $aDbConf['sock'];
+            $this->_sDbname = $aDbConf['name'];
+            $this->_sUser = $aDbConf['user'];
+            $this->_sPassword = $aDbConf['pwd'];
+            if(isset($aDbConf['charset']))
+            	$this->_sCharset = $aDbConf['charset'];
+            if(isset($aDbConf['error_checking']))
+                $this->_bErrorChecking = $aDbConf['error_checking'];
+            if(isset($aDbConf['storage_engine']))
+            	$this->_sStorageEngine = $aDbConf['storage_engine'];
+        }
+
+        @set_exception_handler(array($this, 'pdoExceptionHandler'));
+    }
+
+    /**
+     * Prevent cloning the instance
+     */
+    public function __clone()
+    {
+        if (isset($GLOBALS['bxDolClasses'][get_class($this)]))
+            trigger_error('Clone is not allowed for the class: ' . get_class($this), E_USER_ERROR);
+    }
+
+    /**
+     * Get singleton instance of the class
+     */
+    public static function getInstance()
+    {
+        $null = null;
+        return self::getInstanceWithConf(false, $null);
+    }
+    
+    public static function getInstanceWithConf($aDbConf, &$sError)
+    {
+        if(!isset($GLOBALS['bxDolClasses'][__CLASS__])) {
+            if($aDbConf === false && !defined('BX_DATABASE_HOST'))
+                return null;
+
+            $o = new BxDolDb($aDbConf);
+            $sErrorMessage = $o->connect();
+            if($sErrorMessage) {
+                if($sError !== null)
+                    $sError = $sErrorMessage;
+
+                return null;
+            }
+
+			$GLOBALS['bxDolClasses'][__CLASS__] = $o;
+        }
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    public static function getLink()
+    {
+    	return self::$_rLink;
+    }
+
+    public function setReadOnlyMode($b)
+    {
+        if ($b == self::$_bReadOnlyMode)
+            return;
+        self::$_bReadOnlyMode = $b;
+        if (self::$_bMultuServersMode) {
+            $this->disconnect();
+            $this->connect();
+        }
+    }
+
+    protected function balancer()
+    {
+        self::$_bMultuServersMode = false;
+
+        // regular mode - one DB server
+        if (!is_array($this->_sHost))
+            return array($this->_sHost, $this->_sPort, $this->_sSocket, $this->_sUser, $this->_sPassword, $this->_sDbname);
+
+        $iServersNum = count($this->_sHost);
+        if ($iServersNum > 1)
+            self::$_bMultuServersMode = true;
+
+        // in read/write mode always use first server, which should be always master (also check for regular mode but with array with 1 item)        
+        if (!self::$_bReadOnlyMode || 1 == $iServersNum)
+            $i = 0;
+        // in case of 2 servers - always use second server, in read only mode
+        elseif (2 == $iServersNum)
+            $i = 1;
+        // when there are more that 2 servers, randomly select read only server
+        else
+            $i = rand(1, $iServersNum-1);
+            
+        return array($this->_sHost[$i], $this->_sPort[$i], $this->_sSocket[$i], $this->_sUser[$i], $this->_sPassword[$i], $this->_sDbname[$i]);
+    }
+
+    /**
+     * connect to database with appointed parameters
+     */
+    public function connect()
+    {
+    	if(self::$_rLink)
+    		return;
+
+        list ($sHost, $sPort, $sSocket, $sUser, $sPassword, $sDBName) = $this->balancer();
+
+    	try {   
+	    	$sDsn = "mysql:host=" . $sHost . ";";
+	   		$sDsn .= $sPort ? "port=" . $sPort . ";" : "";
+	   		$sDsn .= $sSocket ? "unix_socket=" . $sSocket . ";" : "";
+	    	$sDsn .= "dbname=" . $sDBName . ";charset=" . $this->_sCharset;
+
+	        self::$_rLink = new PDO($sDsn, $sUser, $sPassword, array(
+				PDO::ATTR_ERRMODE => $this->_iPdoErrorMode,
+				PDO::ATTR_DEFAULT_FETCH_MODE => $this->_iPdoFetchType,
+				PDO::ATTR_PERSISTENT => $this->_bPdoPersistent
+	        ));
+
+            $this->pdoExec("SET NAMES 'utf8mb4' COLLATE '" . (defined('BX_DATABASE_COLLATE') ? BX_DATABASE_COLLATE : 'utf8mb4_unicode_ci') . "'");
+            $this->pdoExec("SET sql_mode = ''");
+            if (0 == strcasecmp($this->_sStorageEngine, 'INNODB'))
+                $this->pdoExec("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE");
+
+            $sVer = $this->getVersion();
+            $sStorageEngine = !$sVer || version_compare($sVer, '5.7.5', '>=') ? 'default_storage_engine' : 'storage_engine';
+            $this->pdoExec("SET $sStorageEngine=" . $this->_sStorageEngine);
+
+			$this->cleanMemoryAll();
+    	}
+    	catch (PDOException $oException) {
+    		$oException->errorInfo[self::$_sErrorKey] = array(
+    			'code' => BX_DB_ERR_CONNECT_FAILD,
+    			'message' => $oException->getMessage(),
+    			'trace' => $oException->getTrace()
+    		);
+
+    		throw $oException;
+    	}
+    }
+
+    /**
+     * close mysql connection
+     */
+    public function disconnect()
+    {
+        self::$_rLink = null;
+    }
+
+    /**
+     * check mysql connection
+     */
+    public function ping()
+    {
+    	try {
+    		$this->pdoQuery("SELECT 1");
+    	}
+    	catch (PDOException $e) {
+    		return false;
+    	}
+
+    	return true;
+    }
+
+    /**
+     * Can be used to execute queries which shouldn't return data
+     */
+    public function pdoExec($sQuery)
+    {
+    	return self::$_rLink->exec($sQuery);
+    }
+
+    /**
+     * Executes query and returns PDOStatement object or false 
+     */
+	public function pdoQuery($sQuery)
+    {
+    	return self::$_rLink->query($sQuery);
+    }
+
+    /**
+     * database query exception handler for exceptions appeared out of the try/catch block
+     */
+    public function pdoExceptionHandler($oException)
+    {
+        if(!($oException instanceof PDOException)) {
+            throw $oException;
+            return;
+        }
+
+		if(!isset($oException->errorInfo[self::$_sErrorKey]))
+			$oException->errorInfo[self::$_sErrorKey] = array(
+				'code' => BX_DB_ERR_QUERY_ERROR,
+				'message' => !empty($oException->errorInfo[2]) ? $oException->errorInfo[2] : $oException->getMessage(),
+				'trace' => $oException->getTrace()
+			);
+
+    	$this->error($oException->errorInfo[self::$_sErrorKey]);
+
+        exit(255);
+    }
+
+    /**
+     * get mysql option
+     */
+    function getOption($sName)
+    {
+    	$oStatement = $this->pdoQuery("SELECT @@{$sName}");
+    	return $this->getOne($oStatement);
+    }
+
+    /**
+     * execute sql query and return one value result
+     */
+    public function getOne($oStatement, $aBindings = array(), $iIndex = 0)
+    {
+        if(!$oStatement)
+			return false;
+		else if(!($oStatement instanceof PDOStatement) && is_string($oStatement))
+			$oStatement = $this->prepare($oStatement);
+
+		$aResult = array();
+		if($this->res($oStatement, $aBindings))
+            $aResult = $oStatement->fetch(PDO::FETCH_NUM);
+
+        return is_array($aResult) && count($aResult) ? $aResult[$iIndex] : false;
+    }
+
+    /**
+     * execute sql query and return one row result
+     */
+    function getRow($oStatement, $aBindings = array(), $iFetchType = PDO::FETCH_ASSOC)
+    {
+    	$aResult = array();
+        if(!$oStatement)
+            return $aResult;
+		else if(!($oStatement instanceof PDOStatement) && is_string($oStatement))
+			$oStatement = $this->prepare($oStatement);
+
+        if(!in_array($iFetchType, array(PDO::FETCH_NUM, PDO::FETCH_ASSOC, PDO::FETCH_BOTH)))
+            $iFetchType = $this->_iPdoFetchType;
+
+        if(!$this->res($oStatement, $aBindings))
+        	return array();
+
+		$aResult = $oStatement->fetch($iFetchType);
+		if($aResult === false)
+        	return array();
+
+        return $aResult;
+    }
+
+    /**
+     * execute sql query and return a column as result
+     */
+    function getColumn($oStatement, $aBindings = array(), $iFetchColumnNumber = 0)
+    {
+    	$aResult = array();
+        if(!$oStatement)
+            return $aResult;
+		else if(!($oStatement instanceof PDOStatement) && is_string($oStatement))
+			$oStatement = $this->prepare($oStatement);
+
+        if(!$this->res($oStatement, $aBindings))
+        	return array();
+
+		$aResult = $oStatement->fetchAll(PDO::FETCH_COLUMN, $iFetchColumnNumber);
+		if($aResult === false)
+        	return array();
+
+        return $aResult;
+    }
+
+	/**
+     * execute sql query and return the first row of result
+     * and keep $array type and poiter to all data
+     */
+    public function getFirstRow($oStatement, $aBindings = array(), $iFetchType = PDO::FETCH_ASSOC)
+    {
+        if(!$oStatement)
+            return array();
+		else if(!($oStatement instanceof PDOStatement) && is_string($oStatement))
+			$oStatement = $this->prepare($oStatement);
+
+        if(!in_array($iFetchType, array(PDO::FETCH_NUM, PDO::FETCH_ASSOC, PDO::FETCH_BOTH)))
+            $iFetchType = $this->_iPdoFetchType;
+
+        if(!$this->res($oStatement, $aBindings)) 
+        	return array();
+
+        $aResult = $oStatement->fetch($iFetchType);
+        if($aResult === false)
+        	return array();
+
+		$this->_oStatement = $oStatement;
+        return $aResult;
+    }
+
+    /**
+     * return next row of pointed last getFirstRow calling data
+     */
+    public function getNextRow($iFetchType = PDO::FETCH_ASSOC)
+    {
+    	if(!$this->_oStatement)
+            return array();
+
+		if(!in_array($iFetchType, array(PDO::FETCH_NUM, PDO::FETCH_ASSOC, PDO::FETCH_BOTH)))
+            $iFetchType = $this->_iPdoFetchType;
+
+		$aResult = $this->_oStatement->fetch($iFetchType);
+		if($aResult !== false)
+			return $aResult;
+
+		$this->_oStatement = null;
+    	return array();
+    }
+
+	/**
+     * execute sql query and return table of records as result
+     */
+    public function getAll($oStatement, $aBindings = array(), $iFetchType = PDO::FETCH_ASSOC)
+    {
+    	$aResult = array();
+        if(!$oStatement)
+            return $aResult;
+		else if(!($oStatement instanceof PDOStatement) && is_string($oStatement))
+			$oStatement = $this->prepare($oStatement);
+
+        if(!in_array($iFetchType, array(PDO::FETCH_NUM, PDO::FETCH_ASSOC, PDO::FETCH_BOTH)))
+            $iFetchType = $this->_iPdoFetchType;
+
+        if(!$this->res($oStatement, $aBindings))
+        	return array();
+
+		$aResult = $oStatement->fetchAll($iFetchType);
+		if($aResult === false)
+        	return array();
+
+        return $aResult;
+    }
+
+    /**
+     * Executes sql query and returns table of records as result.
+     * 
+     * @deprecated use getAll instead.
+     */
+    public function fillArray($oStatement, $aBindings = array(), $iFetchType = PDO::FETCH_ASSOC)
+    {
+    	return $this->getAll($oStatement, $aBindings, $iFetchType);
+    }
+
+	/**
+     * execute sql query and return table of records as result
+     */
+    public function getAllWithKey($oStatement, $sFieldKey, $aBindings = array(), $iFetchType = PDO::FETCH_ASSOC)
+    {
+    	$aResult = array();
+        if(!$oStatement)
+            return $aResult;
+		else if(!($oStatement instanceof PDOStatement) && is_string($oStatement))
+			$oStatement = $this->prepare($oStatement);
+
+        $aRow = $this->getFirstRow($oStatement, $aBindings, $iFetchType);
+        while(!empty($aRow)) {
+        	$aResult[$aRow[$sFieldKey]] = $aRow;
+
+        	$aRow = $this->getNextRow($iFetchType);
+        }
+
+        return $aResult;
+    }
+
+    /**
+     * execute sql query and return table of records as result
+     */
+    public function getPairs($oStatement, $sFieldKey, $sFieldValue, $aBindings = array(), $iFetchType = PDO::FETCH_ASSOC)
+    {
+    	$aResult = array();
+        if(!$oStatement)
+            return $aResult;
+		else if(!($oStatement instanceof PDOStatement) && is_string($oStatement))
+			$oStatement = $this->prepare($oStatement);
+
+        $aRow = $this->getFirstRow($oStatement, $aBindings, $iFetchType);
+        while(!empty($aRow)) {
+        	$aResult[$aRow[$sFieldKey]] = $aRow[$sFieldValue];
+
+        	$aRow = $this->getNextRow($iFetchType);
+        }
+
+        return $aResult;
+    }
+
+    /**
+     * return number of affected rows in current mysql result
+     * 
+     * NOTE: PDOStatement::rowCount works for SELECT queries in MySQL.
+     * So, this method should be rewritten if the other DB engine will be used.
+     */
+    public function getNumRows($oStatement = null)
+    {
+    	if($oStatement && ($oStatement instanceof PDOStatement))
+    		return $oStatement->rowCount();
+
+    	if($this->_oStatement && ($this->_oStatement instanceof PDOStatement))
+    		return $this->_oStatement->rowCount();
+
+    	return 0;
+    }
+
+    /**
+     * returns the number of rows affected by the last DELETE, INSERT, or UPDATE statement. 
+     */
+    public function getAffectedRows($oStatement = null)
+    {
+        return $this->getNumRows($oStatement);
+    }
+
+    public function lastId()
+    {
+        return self::$_rLink->lastInsertId();
+    }
+
+    /**
+     * execute any query return number of rows affected/false
+     */
+    public function query($oStatement, $aBindings = array(), $bVerbose = null)
+    {
+    	if(!$oStatement)
+            return false;
+		else if(!($oStatement instanceof PDOStatement) && is_string($oStatement))
+			$oStatement = $this->prepare($oStatement);
+
+        if($this->res($oStatement, $aBindings, $bVerbose))
+            return $oStatement->rowCount();
+
+        return false;
+    }
+
+    /**
+     * execute any query
+     */
+    public function res($oStatement, $aBindings = array(), $bVerbose = null)
+    {
+		if(!$oStatement || !($oStatement instanceof PDOStatement))
+            return false;
+
+		if($oStatement->errorCode() == BX_PDO_STATE_SUCCESS)
+			return true;
+
+        if(isset($GLOBALS['bx_profiler']))
+        	$GLOBALS['bx_profiler']->beginQuery($oStatement->queryString);
+
+        for ($iAttempts = 3; $iAttempts > 0; --$iAttempts) {
+
+            $oException = null;
+            $iErrorCode = 0;
+            $bResult = false;
+            try {
+                $bResult = $this->executeStatement($oStatement, $aBindings, $bVerbose, $iErrorCode);
+            } catch (PDOException $oException) {
+                $iErrorCode = $oException->errorInfo[1];
+            }
+
+            // if deadlock timeout - try again, make sure that deadlock timeout is short
+            if (!$bResult && (1205 == $iErrorCode || 1213 == $iErrorCode)) {
+                continue;
+            }
+
+	    	// if mysql connection is lost - reconnect and try again
+            if(!$bResult && (2006 == $iErrorCode || !$this->ping())) {
+                $this->disconnect();
+                $this->connect();
+                continue;
+            }
+
+            // in all other cases exit from the loop
+            if ($oException)
+                throw $oException;
+            break;
+        }
+
+        if(isset($GLOBALS['bx_profiler']))
+        	$GLOBALS['bx_profiler']->endQuery($oStatement);
+
+		//is needed for SILENT mode
+		if(!$bResult && !empty($this->_aError))
+			$this->error($this->_aError);
+
+        return $bResult;
+    }
+
+    /**
+     * get mysql server info
+     */
+    public function getServerInfo()
+    {
+    	return self::$_rLink->getAttribute(PDO::ATTR_SERVER_VERSION);
+    }
+
+    /**
+     * get mysql version
+     */
+    public function getVersion()
+    {
+        $s = $this->getOne("SELECT VERSION()");
+        return preg_match("/([0-9\.]+)/", $s, $m) ? $m[1] : false;
+    }
+    
+    /**
+     * get list of tables in database
+     */
+    public function listTables()
+    {
+        $sDBName = is_array($this->_sDbname) ? $this->_sDbname[0] : $this->_sDbname;
+    	$oStatement = $this->pdoQuery("SHOW TABLES FROM `" . $sDBName . "`");
+
+        return $this->getColumn($oStatement);
+    }
+
+    public function getFields($sTable)
+    {
+    	$oStatement = $this->pdoQuery("SHOW COLUMNS FROM `" . $sTable . "`");
+    	$aFields = $this->getAll($oStatement);
+
+        $aResult = array('original' => array(), 'uppercase' => array());
+        foreach($aFields as $aField) {
+            $aResult['original'][] = $aField['Field'];
+            $aResult['uppercase'][] = strtoupper($aField['Field']);
+        }
+
+        return $aResult;
+    }
+
+    public function fetchField($oStatement, $iField, $aBindings = array())
+    {
+        $aResult = array();
+        if(!$oStatement)
+            return $aResult;
+
+        else if(!($oStatement instanceof PDOStatement) && is_string($oStatement)) {
+            $oStatement = $this->prepare($oStatement);
+            if(!$this->res($oStatement, $aBindings))
+                return $aResult;
+        }
+
+        return $oStatement->getColumnMeta($iField);
+    }
+
+    public function isTableExists($sTable)
+    {
+        $aTableNames = $this->listTables();
+        foreach($aTableNames as $iKey => $sTableName)
+            $aTableNames[$iKey] = strtoupper($sTableName);
+
+        return in_array(strtoupper($sTable), $aTableNames);
+    }
+
+    public function isFieldExists($sTable, $sFieldName)
+    {
+        $aFields = $this->getFields($sTable);
+        return in_array(strtoupper($sFieldName), $aFields['uppercase']);
+    }
+
+	public function isIndexExists($sTable, $sIndexName)
+	{
+		$bIndex = false;
+
+        $aIndexes = $this->getAll("SHOW INDEXES FROM `" . $sTable . "`");
+        foreach($aIndexes as $aIndex)
+			if($aIndex['Key_name'] == $sIndexName) {
+				$bIndex = true;
+				break;
+			}
+
+		return $bIndex;
+	}
+
+    public function error($aError)
+    {
+    	$sErrorType = self::$_aErrors[$aError['code']];
+
+    	$bVerbose = isset($aError['verbose']) ? (bool)$aError['verbose'] : $this->_bErrorChecking;
+        if(!$bVerbose) {
+            $this->log($sErrorType . ': ' . $aError['message']);
+            if (!defined('BX_DOL_INSTALL')) // this is needed to display error during installation
+    			return;
+        }
+
+        if((defined('BX_DB_FULL_VISUAL_PROCESSING') && BX_DB_FULL_VISUAL_PROCESSING) || defined('BX_DOL_INSTALL')) {
+            $sOutput = '<div style="border:2px solid red;padding:4px;width:600px;margin:0px auto;">';
+            $sOutput .= '<div style="text-align:center;background-color:red;color:white;font-weight:bold;">Error</div>';
+            $sOutput .= '<div style="text-align:center;">' . $sErrorType . '</div>';
+            if((defined('BX_DB_FULL_DEBUG_MODE') && BX_DB_FULL_DEBUG_MODE) || defined('BX_DOL_INSTALL'))
+				$sOutput .= $this->errorOutput($aError);
+            $sOutput .= '</div>';
+        } 
+
+        if (self::$_rLink) { // connection errors aren't logged since bx_log required DB connection
+            bx_log('sys_db', "$sErrorType\n" . 
+                (empty($aError['message']) ? '' : "  Error: {$aError['message']}\n") . 
+                (empty($aError['query']) ? '' : "  Query: {$aError['query']}\n") . 
+                (!function_exists('getLoggedId') || !getLoggedId() ? '' : "  Account ID: " . getLoggedId() . "\n")
+            );
+        }
+
+        if(defined('BX_DB_DO_EMAIL_ERROR_REPORT') && BX_DB_DO_EMAIL_ERROR_REPORT) {
+            $sSiteTitle = $this->getParam('site_title');
+
+            $sMailBody = "Database error in " . $sSiteTitle . "<br /><br /> \n";
+            $sMailBody .= $this->errorOutput($aError);
+            $sMailBody .= "<hr />Auto-report system";
+
+            sendMail($this->getParam('site_email'), "Database error in " . $sSiteTitle, $sMailBody, 0, array(), BX_EMAIL_SYSTEM, 'html', true);
+        }
+
+        bx_show_service_unavailable_error_and_exit($sOutput);
+    }
+
+    protected function isParamInCache($sKey)
+    {
+        return is_array(self::$_aParams) && isset(self::$_aParams[$sKey]);
+    }
+
+    public function cacheParams($bForceCacheInvalidate = false, $bForceCacheInvalidateMixed = false)
+    {
+        if ($bForceCacheInvalidate)
+            $this->cacheParamsClear();
+
+        self::$_aParams = $this->fromCache(self::$_sParamsCacheName, 'getPairs', "SELECT `name`, `value` FROM `sys_options`", "name", "value");
+
+        list($sTmplCode, $sTmplName) = BxDolTemplate::retrieveCode();
+        if(!empty($sTmplCode) && !empty($sTmplName)) {
+            $iTmplMix = 0;
+            if(is_array($sTmplCode))
+                list($sTmplCode, $iTmplMix) = $sTmplCode;
+
+            if(empty($iTmplMix))
+                $iTmplMix = (int)$this->getParam($sTmplName . '_default_mix');
+
+            if(!empty($iTmplMix)) {
+                $sCacheNameMixed = self::$_sParamsCacheNameMixed . $sTmplCode .  '_' . $iTmplMix;
+                if($bForceCacheInvalidateMixed)
+                    $this->cacheParamsClear($sCacheNameMixed);
+
+                $aMixed = $this->fromCache($sCacheNameMixed, 'getPairs', "SELECT `option`, `value` FROM `sys_options_mixes2options` WHERE `mix_id`=:mix_id", "option", "value", array(
+                    'mix_id' => $iTmplMix
+                ));
+
+                if(!empty($aMixed) && is_array($aMixed))
+                    self::$_aParams = array_merge(self::$_aParams, $aMixed);
+            }
+        }
+
+        if (empty(self::$_aParams)) {
+            self::$_aParams = array();
+
+            return false;
+        }
+
+        return true;
+    }
+
+    public function cacheParamsClear($sCacheName = '')
+    {
+        if(empty($sCacheName))
+            $sCacheName = self::$_sParamsCacheName;
+
+        return $this->cleanCache($sCacheName);
+    }
+
+    public function isParam($sKey, $bFromCache = true)
+    {
+        if ($bFromCache && $this->isParamInCache($sKey))
+           return true;
+
+        $sQuery = $this->prepare("SELECT `name` FROM `sys_options` WHERE `name` = ? LIMIT 1", $sKey);
+        return $this->getOne($sQuery) == $sKey;
+    }
+
+    public function addParam($sName, $sValue, $iKateg, $sDesc, $sType)
+    {
+        $sQuery = $this->prepare("INSERT INTO `sys_options` SET `category_id` = ?, `name` = ?, `caption` = ?, `value` = ?, `type` = ?", $iKateg, $sName, $sDesc, $sValue, $sType);
+        $this->query($sQuery);
+
+        // renew params cache
+        $this->cacheParams(true);
+    }
+
+    public function getParam($sKey, $bFromCache = true)
+    {
+        if (!$sKey)
+            return false;
+
+        if ($bFromCache && $this->isParamInCache($sKey)) {
+            return self::$_aParams[$sKey];
+        } else {
+            $sQuery = $this->prepare("SELECT `tmo`.`value` AS `value` FROM `sys_options_mixes2options` AS `tmo` INNER JOIN `sys_options_mixes` AS `tm` ON `tmo`.`mix_id`=`tm`.`id` AND `tm`.`active`='1' WHERE `tmo`.`option`=? LIMIT 1", $sKey);
+            $mixedValue = $this->getOne($sQuery);
+            if($mixedValue !== false)
+                return $mixedValue;
+
+            $sQuery = $this->prepare("SELECT `value` FROM `sys_options` WHERE `name` = ? LIMIT 1", $sKey);
+            return $this->getOne($sQuery);
+        }
+    }
+
+    public function setParam($sKey, $mixedValue, $iMixId = 0)
+    {
+    	if(empty($iMixId))
+            $sQuery = $this->prepare("UPDATE `sys_options` SET `value` = ? WHERE `name` = ? LIMIT 1", $mixedValue, $sKey);
+        else
+            $sQuery = $this->prepare("REPLACE INTO `sys_options_mixes2options` SET `option` = ?, `mix_id` = ?, `value` = ?", $sKey, $iMixId, $mixedValue);
+
+        $bResult = (int)$this->query($sQuery) > 0;
+
+        // renew params cache
+        $bResult &= $this->cacheParams(true, !empty($iMixId));
+
+        return $bResult;
+    }
+
+    public function getParamsMix($iId)
+    {
+        return $this->fromCache(self::$_sParamsCacheNameMix . $iId, 'getRow', 'SELECT * FROM `sys_options_mixes` WHERE `id`=:id', array(
+            'id' => $iId
+        ));
+    }
+
+    public function getParamsMixActive($sType)
+    {
+        return $this->getRow("SELECT * FROM `sys_options_mixes` WHERE `type`=:type AND `active`='1'", array(
+            'type' => $sType
+        ));
+    }
+
+    public function getParamsMixes($sType, $mixedPublished = false)
+    {
+        $aBindings = array('type' => $sType);
+
+        $sQuery = "SELECT * FROM `sys_options_mixes` WHERE `type`=:type";
+        if($mixedPublished !== false) {
+            $aBindings['published'] = (int)$mixedPublished;
+
+            $sQuery .= " AND `published`=:published";
+        }
+
+        return $this->getAll($sQuery, $aBindings);
+    }
+
+    public function setTimezone($sTimezone)
+    {
+        $oDate = new DateTime('now', new DateTimeZone($sTimezone));
+        return $this->pdoExec('SET time_zone = "' . $oDate->format('P') . '"') !== false;
+    }
+
+    public function getEncoding()
+    {
+    	$oStatement = $this->pdoQuery('SELECT @@character_set_database');
+    	if($oStatement !== false)
+    		return $this->getOne($oStatement);
+
+    	return false;
+    }
+
+    public function setErrorChecking ($b)
+    {
+        $this->_bErrorChecking = $b;
+    }
+
+    /**
+     * Cache functions.
+     */
+    public function getDbCacheObject ()
+    {
+        if($this->_oDbCacheObject != null)
+			return $this->_oDbCacheObject;
+
+		$sEngine = $this->getParam('sys_db_cache_engine');
+		$this->_oDbCacheObject = bx_instance('BxDolCache'.$sEngine);
+		if(!$this->_oDbCacheObject->isAvailable())
+			$this->_oDbCacheObject = bx_instance('BxDolCacheFile');
+
+		return $this->_oDbCacheObject;
+    }
+
+    public function genDbCacheKey ($sName)
+    {
+        return 'db_' . $sName . '_' . bx_site_hash() . '.php';
+    }
+
+    public function getCache ($sName, $sFunc)
+    {
+        if (!$this->getParam('sys_db_cache_enable'))
+            return false;
+        
+        $oCache = $this->getDbCacheObject ();
+        $sKey = $this->genDbCacheKey($sName);
+        return $oCache->getData($sKey);
+    }
+    
+    public function setCache ($sName, $mData)
+    {
+        if (!$this->getParam('sys_db_cache_enable'))
+            return false;
+        
+        $oCache = $this->getDbCacheObject ();
+        $sKey = $this->genDbCacheKey($sName);
+        $oCache->setData($sKey, $mData);
+    }
+    
+    public function fromCache ($sName, $sFunc)
+    {
+        $aArgs = func_get_args();
+        array_shift ($aArgs); // shift $sName
+        array_shift ($aArgs); // shift $sFunc
+
+        if (!$this->getParam('sys_db_cache_enable'))
+            return call_user_func_array (array ($this, $sFunc), $aArgs); // pass other function parameters as database function parameters
+
+        $oCache = $this->getDbCacheObject ();
+
+        $sKey = $this->genDbCacheKey($sName);
+
+        $mixedRet = $oCache->getData($sKey);
+
+        if ($mixedRet !== null) {
+
+            return $mixedRet;
+
+        } else {
+
+            $mixedRet = call_user_func_array (array ($this, $sFunc), $aArgs); // pass other function parameters as database function parameters
+
+            $oCache->setData($sKey, $mixedRet);
+        }
+
+        return $mixedRet;
+    }
+
+    public function cleanCache ($sName)
+    {
+        $oCache = $this->getDbCacheObject();
+
+        $sKey = $this->genDbCacheKey($sName);
+
+        return $oCache->delData($sKey);
+    }
+
+    public function &fromMemory ($sName, $sFunc)
+    {
+        if(array_key_exists($sName, self::$_aDbCacheData) && !defined('BX_DOL_INSTALL') && !defined('BX_DOL_CRON_EXECUTE'))
+			return self::$_aDbCacheData[$sName];
+
+		$aArgs = func_get_args();
+		array_shift($aArgs); // shift $sName
+		array_shift($aArgs); // shift $sFunc
+		self::$_aDbCacheData[$sName] = call_user_func_array (array ($this, $sFunc), $aArgs); // pass other function parameters as database function parameters
+
+		return self::$_aDbCacheData[$sName];
+    }
+
+    public function cleanMemory ($sName)
+    {
+        if(!isset(self::$_aDbCacheData[$sName])) 
+        	return false;
+
+		unset(self::$_aDbCacheData[$sName]);
+		return true;
+    }
+
+    public function cleanMemoryAll ()
+    {
+        self::$_aDbCacheData = array();
+		return true;
+    }
+
+    /**
+     * It escapes string to pass to mysql query.
+     * Try to use "prepare" function always (@see BxDolDb::prepare), use "escape" only if "prepare" function is not possible at all.
+     * Also consider using "implode_escape" function (@see BxDolDb::implode_escape).
+     *
+     * @param  string  $s string to escape
+     * @return escaped string whcich is ready to pass to SQL query.
+     */
+    public function escape($s)
+    {
+    	try {
+    		$s = self::$_rLink->quote($s);
+    	}
+    	catch (PDOException $oException) {
+    		$oException->errorInfo[self::$_sErrorKey] = array(
+    			'code' => BX_DB_ERR_ESCAPE,
+    			'message' => $oException->getMessage(),
+    			'trace' => $oException->getTrace()
+    		);
+
+    		throw $oException;
+    	}
+
+        return $s;
+    }
+
+    /**
+     * This function is usefull when you need to form array of parameters to pass to IN(...) SQL construction.
+     * Example:
+     * @code
+     * $a = array(2, 4.5, 'apple', 'car');
+     * $s = "SELECT * FROM `t` WHERE `a` IN (" . $oDb->implode_escape($a) . ")";
+     * echo $s; // outputs: SELECT * FROM `t` WHERE `a` IN (2, 4.5, 'apple', 'car')
+     * @endcode
+     *
+     * @param $mixed array or parameters or just one paramter
+     * @return string which is ready to pass to IN(...) SQL construction
+     */
+    public function implode_escape ($mixed)
+    {
+        if (is_array($mixed)) {
+            $s = '';
+            foreach ($mixed as $v)
+                $s .= (is_numeric($v) ? $v : $this->escape($v)) . ',';
+            if ($s)
+                return substr($s, 0, -1);
+            else
+                return 'NULL';
+        }
+
+        return is_numeric($mixed) ? $mixed : ($mixed ? $this->escape($mixed) : 'NULL');
+    }
+
+    /**
+     * @deprecated
+     */
+    public function unescape ($mixed)
+    {
+        if(is_array($mixed)) {
+            foreach($mixed as $k => $v)
+				$mixed[$k] = $this->getOne("SELECT '$v'");
+
+            return $mixed;
+        } 
+        else
+            return $this->getOne("SELECT '$mixed'");
+    }
+
+    /**
+     * Prepare SQL query before execution if some arguments are need to be passed to it.
+     * All parameters marked with question (?) symbol in SQL query are replaced with parameters passed after SQL query parameter.
+     * Parameters are properly excaped and surrounded by qutes if needed.
+     * Example:
+     * @code
+     * $sSql = $oDb->prepare("SELECT `a`, `b` from `t` WHERE `c` = ? and `d` = ?", 12, 'aa');
+     * echo $sSql;// outputs: SELECT `a`, `b` from `t` WHERE `c` = 12 and `d` = 'aa'
+     * $a = $oDb->getAll($sSql);
+     * @endcode
+     *
+     * @param  string $sQuery SQL query, parameters for replacing are marked with ? symbol
+     * @param  mixed  $mixed  any number if parameters to replace, number of parameters whould match number of ? symbols in SQL query
+     * @return PDOStatement object with SQL query ready for execution
+     */
+    public function prepare($sQuery)
+    {
+    	if(!self::$_rLink)
+    		return false;
+
+        if (self::$_bMultuServersMode && (0 != strncasecmp(ltrim($sQuery, " \t\n\r(\0\x0B"), 'SELECT', 6)))
+            $this->setReadOnlyMode(false);
+
+        $aArgs = func_get_args();
+        $sQuery = array_shift($aArgs);
+
+        $oStatement = self::$_rLink->prepare($sQuery);
+
+        $iIndex = 1;
+        foreach($aArgs as $mixedArg) {
+        	if(is_null($mixedArg))
+				$iValueType = PDO::PARAM_NULL;
+            else if(is_numeric($mixedArg) && is_int($mixedArg))
+                $iValueType = PDO::PARAM_INT;
+            else
+                $iValueType = PDO::PARAM_STR;
+
+        	$oStatement->bindValue($iIndex++, $mixedArg, $iValueType);
+        }
+
+        return $oStatement;
+    }
+
+    /**
+     * Prepare SQL query before execution if some arguments are need to be passed to it.
+     * All parameters marked with question (?) symbol in SQL query are replaced with parameters passed after SQL query parameter.
+     * Parameters are properly excaped and surrounded by qutes if needed.
+     * Example:
+     * @code
+     * $sSql = $oDb->prepare("SELECT `a`, `b` from `t` WHERE `c` = ? and `d` = ?", 12, 'aa');
+     * echo $sSql;// outputs: SELECT `a`, `b` from `t` WHERE `c` = 12 and `d` = 'aa'
+     * $a = $oDb->getAll($sSql);
+     * @endcode
+     *
+     * @param  string $sQuery SQL query, parameters for replacing are marked with ? symbol
+     * @param  mixed  $mixed  any number if parameters to replace, number of parameters whould match number of ? symbols in SQL query
+     * @return string with SQL query. 
+     */
+    function prepareAsString($sQuery)
+    {
+        $aArgs = func_get_args();
+        $sQuery = array_shift($aArgs);
+
+        $iPos = 0;
+        foreach ($aArgs as $mixedArg) {
+            if (is_null($mixedArg))
+                $s = 'NULL';
+            elseif (is_numeric($mixedArg))
+                $s = $mixedArg;
+            else
+                $s = $this->escape($mixedArg);
+
+            $i = bx_mb_strpos($sQuery, '?', $iPos);
+            $sQuery = bx_mb_substr_replace($sQuery, $s, $i, 1);
+            $iPos = $i + get_mb_len($s);
+        }
+
+        return $sQuery;
+    }
+
+    /**
+     * Convert array of key => values to SQL query.
+     * Array keys are field names and array values are field values.
+     * @param $a array
+     * @param $sDiv fields separator, by default it is ',', another useful value is ' AND '
+     * @param $sOperator operator for comparision: '=' or 'LIKE'
+     * @param $bWildcardSpaceChars substiture any space and dash characters with single wildcard symbol
+     * @return part of SQL query string
+     */
+    public function arrayToSQL($a, $sDiv = ',', $sOperator = '=', $bWildcardSpaceChars = false)
+    {
+        $aOperators = ['=', 'LIKE', 'like'];
+        $sOperator = in_array($sOperator, $aOperators) ? $sOperator : '=';
+        $s = '';
+        foreach($a as $k => $v) {
+            if ($bWildcardSpaceChars)
+                $v = preg_replace('/[\p{Zs}\p{Cc}\p{Pd}]/', '_', $v);
+            $s .= "`{$k}` {$sOperator} " . $this->escape($v) . $sDiv;
+        }
+        return trim($s, $sDiv);
+    }
+
+    protected function log($s)
+    {
+        if (defined('BX_DIRECTORY_PATH_LOGS')) {
+            $sPath = BX_DIRECTORY_PATH_LOGS;
+        }
+        else {
+            $sDirName = pathinfo(__FILE__, PATHINFO_DIRNAME);
+            $sPath = $sDirName . '/../../logs/';
+        }
+        return file_put_contents($sPath . 'db.err.log', date('Y-m-d H:i:s') . "\t" . $s . "\n", FILE_APPEND);
+    }
+
+    public function executeSQL($sPath, $aReplace = array (), $isBreakOnError = true)
+    {
+        if(!file_exists($sPath) || !($rHandler = fopen($sPath, "r")))
+            return array(array ('query' => "fopen($sPath, 'r')", 'error' => 'file not found or permission denied'));
+
+		self::$_rLink->setAttribute(PDO::ATTR_ERRMODE, BX_DB_MODE_SILENT);
+
+        $sQuery = "";
+        $sDelimiter = ';';
+        $aResult = array();
+        while(!feof($rHandler)) {
+            $sStr = trim(fgets($rHandler));
+
+            if(empty($sStr) || $sStr[0] == "" || $sStr[0] == "#" || ($sStr[0] == "-" && $sStr[1] == "-"))
+                continue;
+
+            //--- Change delimiter ---//
+            if(strpos($sStr, "DELIMITER //") !== false || strpos($sStr, "DELIMITER ;") !== false) {
+                $sDelimiter = trim(str_replace('DELIMITER', '', $sStr));
+                continue;
+            }
+
+            $sQuery .= $sStr;
+
+            //--- Check for multiline query ---//
+            if(substr($sStr, -strlen($sDelimiter)) != $sDelimiter)
+                continue;
+
+            //--- Execute query ---//
+            if ($aReplace)
+                $sQuery = str_replace($aReplace['from'], $aReplace['to'], $sQuery);
+            if($sDelimiter != ';')
+                $sQuery = str_replace($sDelimiter, "", $sQuery);
+
+            if($this->query(trim($sQuery), array(), false) === false) {
+                $aResult[] = array('query' => $sQuery, 'error' => $this->_aError['message']);
+                if ($isBreakOnError)
+                    break;
+            }
+
+            $sQuery = "";
+        }
+        fclose($rHandler);
+
+        self::$_rLink->setAttribute(PDO::ATTR_ERRMODE, $this->_iPdoErrorMode);
+
+        return empty($aResult) ? true : $aResult;
+    }
+
+    protected function executeStatement($oStatement, $aBindings = array(), $bVerbose = null, &$iErrorCode = null)
+    {
+        $bResult = false;
+
+        if (!isset($aBindings[0])) {
+    		foreach($aBindings as $sKey => $mixedValue) {
+	    		if(is_null($mixedValue))
+		    		$oStatement->bindValue(":{$sKey}", $mixedValue, PDO::PARAM_NULL);
+                else if(is_numeric($mixedValue) && is_int($mixedValue))
+                    $oStatement->bindValue(":{$sKey}", $mixedValue, PDO::PARAM_INT);
+                else
+                    $oStatement->bindValue(":{$sKey}", $mixedValue, PDO::PARAM_STR);
+            }
+            $aBindings = array();
+        }
+
+    	switch (self::$_rLink->getAttribute(PDO::ATTR_ERRMODE)) {
+    		case PDO::ERRMODE_SILENT:
+    			$bResult = $this->executeStatementSilent($oStatement, $aBindings, $bVerbose);
+    			break;
+
+    		case PDO::ERRMODE_EXCEPTION:
+    			$bResult = $this->executeStatementException($oStatement, $aBindings, $bVerbose);
+    			break;
+    	}
+
+        if (!$bResult && !is_null($iErrorCode)) {
+            $aError = $oStatement->errorInfo();
+            $iErrorCode = $aError[1];
+        }
+
+    	return $bResult;
+    }
+
+    protected function executeStatementException($oStatement, $aBindings = array(), $bVerbose = null)
+    {
+    	$bResult = false;
+
+    	try {
+			$bResult = $oStatement->execute(!empty($aBindings) && is_array($aBindings) ? $aBindings : null);
+		}
+		catch (PDOException $oException) {
+			$aError = $oStatement->errorInfo();
+
+			$oException->errorInfo[self::$_sErrorKey] = array(
+				'code' => BX_DB_ERR_QUERY_ERROR,
+				'message' => !empty($aError[2]) ? $aError[2] : $oException->getMessage(),
+				'query' => $oStatement->queryString,
+				'trace' => $oException->getTrace(),
+				'verbose' => $bVerbose
+			);
+
+			throw $oException;
+		}
+
+		return $bResult;
+    }
+
+    protected function executeStatementSilent($oStatement, $aBindings = array(), $bVerbose = null)
+    {
+    	$bResult = $oStatement->execute(!empty($aBindings) && is_array($aBindings) ? $aBindings : null);
+    	if($bResult)
+    		return true;
+
+		$aError = $oStatement->errorInfo();
+
+        $aTrace = debug_backtrace();
+        unset($aTrace[0]);
+
+		$this->_aError = array(
+			'code' => BX_DB_ERR_QUERY_ERROR,
+			'message' => !empty($aError[2]) ? $aError[2] : '',
+			'query' => $oStatement->queryString,
+			'trace' => $aTrace,
+			'verbose' => $bVerbose
+		);
+
+		return false;
+    }
+
+	protected function errorOutput($aError)
+    {
+		$aErrorLocation = array();
+
+        if(!empty($aError['query']) && !empty($aError['trace']))
+            foreach($aError['trace'] as $aCall )
+                if(isset($aCall['args']) && is_array($aCall['args']))
+                    foreach($aCall['args'] as $argNum => $argVal)
+                        if((is_string($argVal) && strcmp($argVal, $aError['query']) == 0) || ($argVal instanceof PDOStatement && strcmp($argVal->queryString, $aError['query']) == 0)) {
+                            $aErrorLocation['file'] = isset($aCall['file']) ? $aCall['file'] : (isset($aCall['class']) ? 'class: ' . $aCall['class'] : 'undefined');
+                            $aErrorLocation['line'] = isset($aCall['line']) ? $aCall['line'] : 'undefined';
+                            $aErrorLocation['function'] = $aCall['function'];
+                            $aErrorLocation['arg'] = $argNum;
+                        }
+
+        $sOutput = '';
+        
+        if(!empty($aError['query']))
+            $sOutput .= '<p><b>Query:</b><br />' . bx_process_output($aError['query']) . '</p>';
+
+        if(!empty($aError['message']))
+            $sOutput .= '<p><b>Mysql error:</b><br />' . $aError['message'] . '</p>';
+
+		if(!empty($aErrorLocation))
+			$sOutput .= '<p><b>Location:</b><br />The error was found in <b>' . $aErrorLocation['function'] . '</b> function in the file <b>' . $aErrorLocation['file'] . '</b> at line <b>' . $aErrorLocation['line'] . '</b>.</p>';
+
+        $sOutput .= '<p><b>collation_connection:</b><br />' . $this->getOne("SELECT @@collation_connection") . '</p>';
+        
+		if(!empty($aError['trace'])) {
+            $sBackTrace = print_r($aError['trace'], true);
+            if (defined ('BX_DATABASE_USER') && !is_array(BX_DATABASE_USER))
+                $sBackTrace = str_replace('[_sUser:protected] => ' . BX_DATABASE_USER, '[_sUser:protected] => *****', $sBackTrace);
+            if (defined ('BX_DATABASE_PASS') && !is_array(BX_DATABASE_PASS))
+                $sBackTrace = str_replace('[_sPassword:protected] => ' . BX_DATABASE_PASS, '[_sPassword:protected] => *****', $sBackTrace);
+
+			$sOutput .= '<div><b>Debug backtrace:</b></div><div style="overflow:scroll;height:300px;border:1px solid gray;"><pre>' . htmlspecialchars_adv($sBackTrace) . '</pre></div>';
+		}
+
+		if(!empty(self::$_aParams)) {
+			$sSettings = var_export(self::$_aParams, true);
+
+			$sOutput .= '<div><b>Settings:</b></div><div style="overflow:scroll;height:300px;border:1px solid gray;"><pre>' . htmlspecialchars_adv($sSettings) . '</pre></div>';
+		}
+
+		$sOutput .= '<p><b>Called script:</b><br />' . $_SERVER['PHP_SELF'] . '</p>';
+
+		if(!empty($_REQUEST)) {
+			$sRequest = var_export($_REQUEST, true);
+
+			$sOutput .= '<p><b>Request parameters:</b><br /><pre>' . htmlspecialchars_adv($sRequest) . '</pre></p>';
+		}
+
+        return $sOutput;
+    }
+}
+
+/**
+ * Create the very first instance and initiate connetion to database.
+ */
+BxDolDb::getInstance();
+
+function getParam($sParamName, $bUseCache = true)
+{
+    if (bx_is_api() && $sParamName == 'permalinks_seo_links')
+        return true;
+    
+    $oObj = BxDolDb::getInstance();
+    if ($oObj === null)
+        return false;
+       
+     return $oObj->getParam($sParamName, $bUseCache);
+}
+
+function setParam($sParamName, $sParamVal)
+{
+    $oObj = BxDolDb::getInstance();
+    if ($oObj === null)
+        return false;
+    
+    return $oObj->setParam($sParamName, $sParamVal);
+}
+
+/** @} */

+ 226 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolInstallerUtils.php

@@ -0,0 +1,226 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+if (!defined('BX_FORCE_AUTOUPDATE_MAX_CHANGED_FILES_PERCENT'))
+    define('BX_FORCE_AUTOUPDATE_MAX_CHANGED_FILES_PERCENT', 0.05);
+
+define('BX_FORCE_USE_FTP_FILE_TRANSFER', false);
+
+class BxDolInstallerUtils extends BxDolIO
+{
+    protected $_aNonHashableFiles = array();
+
+    public function __construct()
+    {
+        parent::__construct();
+    }
+
+    static public function isAllowUrlInclude()
+    {
+        if (version_compare(phpversion(), "5.2", ">") == 1 && version_compare(phpversion(), "8.0", "<") == 1) {
+            $sAllowUrlInclude = ini_get('allow_url_include');
+            return !($sAllowUrlInclude == 0);
+        };
+        return false;
+    }
+
+    static public function getModuleConfig($mixed)
+    {
+    	$sConfig = '';
+    	if(is_array($mixed) && !empty($mixed['path']))
+            $sConfig = BX_DIRECTORY_PATH_MODULES . $mixed['path'] . 'install/config.php';
+        else if(is_string($mixed))
+            $sConfig = $mixed;
+        else 
+            return array();
+
+    	if(!file_exists($sConfig))
+            return array();
+
+        ob_start();
+        include($sConfig);
+        ob_end_clean();
+
+        return isset($aConfig) ? $aConfig : array();
+    }
+
+    static public function isModuleInstalled($sUri)
+    {
+        return BxDolModuleQuery::getInstance()->isModule($sUri);
+    }
+
+    /**
+     * Set module for delayed uninstall
+     */
+    static public function setModulePendingUninstall($sUri, $bPendingUninstall = true)
+    {
+        return BxDolModuleQuery::getInstance()->setModulePendingUninstall($sUri, $bPendingUninstall);
+    }
+
+    /**
+     * Check if module is pending for uninstall
+     */
+    static public function isModulePendingUninstall($sUri)
+    {
+        $a = BxDolModuleQuery::getInstance()->getModuleByUri($sUri);
+        return $a['pending_uninstall'];
+    }
+
+    /**
+     * Check all pending for uninstallation modules and uninstall them if no pending for deletion files are found
+     */
+    static public function checkModulesPendingUninstall()
+    {
+        $a = BxDolModuleQuery::getInstance()->getModules();
+        foreach ($a as $aModule) {
+
+            // after we make sure that all pending for deletion files are deleted
+            if (!$aModule['pending_uninstall'] || BxDolStorage::isQueuedFilesForDeletion($aModule['name']))
+                continue;
+
+            // remove pending uninstall flag
+            self::setModulePendingUninstall($aModule['uri'], false);
+
+            // perform uninstallation
+            $aResult = BxDolStudioInstallerUtils::getInstance()->perform($aModule['path'], 'uninstall');
+
+            // send email nofitication
+            $aTemplateKeys = array(
+                'Module' => $aModule['title'],
+                'Result' => _t('_Success'),
+                'Message' => '',
+            );
+
+            if ($aResult['code'] > 0) {
+                $aTemplateKeys['Result'] = _t('_Failed');
+                $aTemplateKeys['Message'] = $aResult['message'];
+            }
+
+            $aMessage = BxDolEmailTemplates::getInstance()->parseTemplate('t_DelayedModuleUninstall', $aTemplateKeys);
+            sendMail (getParam('site_email'), $aMessage['Subject'], $aMessage['Body'], 0, array(), BX_EMAIL_SYSTEM);
+        }
+    }
+
+    public function getSubtypes($sModule)
+    {
+        $iResult = 0;
+        $oModule = BxDolModule::getInstance($sModule);
+        if(!$oModule)
+            return $iResult;
+
+        $sMethod = 'getSubtypes';
+        if(method_exists($oModule, $sMethod))
+            return $oModule->$sMethod();
+
+        $sSrvMethod = 'act_as_profile';
+        if($oModule instanceof iBxDolProfileService && bx_is_srv_ii($sModule, $sSrvMethod)) {
+            $iResult |= pow(2, BX_DOL_MODULE_SUBTYPE_CONTEXT);
+
+            if(bx_srv_ii($sModule, $sSrvMethod))
+                $iResult |= pow(2, BX_DOL_MODULE_SUBTYPE_PROFILE);
+
+            return $iResult;
+        }
+
+        $sSrvMethod = 'is_allowed_post_in_context';
+        if($oModule instanceof iBxDolContentInfoService && !($oModule instanceof iBxDolProfileService) && bx_is_srv_ii($sModule, $sSrvMethod) && bx_srv_ii($sModule, $sSrvMethod)) {
+            $iResult |= pow(2, BX_DOL_MODULE_SUBTYPE_TEXT);
+
+            if($oModule instanceof BxBaseModFilesModule)
+                $iResult |= pow(2, BX_DOL_MODULE_SUBTYPE_FILE);
+
+            return $iResult;
+        }
+
+        if($oModule instanceof BxBaseModPaymentModule) {
+            $iResult |= pow(2, BX_DOL_MODULE_SUBTYPE_PAYMENT);
+
+            return $iResult;
+        }
+
+        if($oModule instanceof BxBaseModNotificationsModule) {
+            $iResult |= pow(2, BX_DOL_MODULE_SUBTYPE_NOTIFICATION);
+
+            return $iResult;
+        }
+
+        if($oModule instanceof BxBaseModConnectModule) {
+            $iResult |= pow(2, BX_DOL_MODULE_SUBTYPE_CONNECT);
+
+            return $iResult;
+        }
+
+        return $iResult;
+    }
+
+    /**
+     * Generate hash for module files.
+     * @param $sPath module's root folder
+     * @param $aFiles array to fill with files hashes
+     */
+    public function hashFiles($sPath, &$aFiles)
+    {
+        $aExcludes = array('.', '..', 'error_log', 'php.ini', '.DS_Store', 'Thumbs.db');
+        if (file_exists($sPath) && is_dir($sPath) && ($rSource = opendir($sPath))) {
+            while (($sFile = readdir($rSource)) !== false) {
+                if ('.' == $sFile[0] || in_array($sFile, $aExcludes))
+                    continue;
+                
+                if (in_array($this->filePathWithoutBase($sPath . $sFile), $this->_aNonHashableFiles))
+                    continue;                
+
+                if (is_dir($sPath . $sFile))
+                    $this->hashFiles($sPath . $sFile . '/', $aFiles);
+                else
+                    $aFiles[] = $this->hashInfo($sPath . $sFile);
+            }
+            closedir($rSource);
+        } elseif (file_exists($sPath) && is_file($sPath)) {
+            $aFiles[] = $this->hashInfo($sPath);
+        }
+    }
+
+    /** 
+     * Check module's files hashes. For system files use @see BxDolInstallerHasher class.
+     * @param $aFiles current files checksums 
+     * @param $iModuleId module id
+     * @return empty array on success, or array of files which checksum was changed 
+     */
+    public function hashCheck($aFiles, $iModuleId)
+    {
+        $oDb = bx_instance('BxDolStudioInstallerQuery');
+
+        $aFilesOrig = $oDb->getModuleTrackFiles($iModuleId);
+        $aFailesChanged = array();
+        foreach ($aFiles as $aFile)
+            if(!isset($aFilesOrig[$aFile['file']]) || $aFilesOrig[$aFile['file']]['hash'] != $aFile['hash'])
+                $aFailesChanged[] = $aFile['file'];
+
+		$fChangedPercent = 0;
+        if(count($aFilesOrig) != 0)
+            $fChangedPercent = count($aFailesChanged) / count($aFilesOrig);
+
+        return array($aFailesChanged, $fChangedPercent);
+    }
+    
+    protected function hashInfo($sPath)
+    {
+        return array(
+            'file' => $this->filePathWithoutBase($sPath),
+            'hash' => md5_file($sPath)
+        );
+    }
+
+    protected function filePathWithoutBase($sPath)
+    {
+        return bx_ltrim_str($sPath, BX_DIRECTORY_PATH_ROOT);
+    }
+}
+
+/** @} */

+ 343 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolLiveUpdates.php

@@ -0,0 +1,343 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolLiveUpdates extends BxDolFactory implements iBxDolSingleton
+{
+    protected $_oQuery;
+
+    /*
+     * List of all systems (active and inactive) with full description.
+     */
+    protected $_aSystems;
+
+    /*
+     * List of active systems without any additional info.
+     */
+    protected $_aSystemsActive;
+
+    /*
+     * List of dynamically added transient systems without any additional info. 
+     * It depends on the page where a user is.  
+     */
+    protected $_aSystemsTransient;  
+
+    protected $_iProfileId;
+
+    protected $_iInterval;
+
+    protected $_oCacheObject;
+    protected $_sCacheKeySystems;
+    protected $_sCacheKeyData;
+    protected $_iCacheTTL;
+
+    protected $_sJsClass;
+    protected $_sJsObject;
+
+    protected function __construct()
+    {
+        if (isset($GLOBALS['bxDolClasses'][get_class($this)]))
+            trigger_error ('Multiple instances are not allowed for the class: ' . get_class($this), E_USER_ERROR);
+
+        parent::__construct();
+
+        /**
+         * Note. Currently Live Updates are associated with profiles (Profile ID) and therefore they are used for logged in members only.
+         * If it's needed Session ID can be used instead of Profile ID. In this case Live Updates can be used for visitors too. Don't forget
+         * to update BxBaseLiveUpdates::init if Session ID will be used.
+         */
+        $this->_iProfileId = (int)bx_get_logged_profile_id();
+        if(!$this->_iProfileId)
+            return;
+
+        $this->_oQuery = new BxDolLiveUpdatesQuery();
+
+        $this->_iInterval = (int)$this->_oQuery->getParam('sys_live_updates_interval');
+        
+        $this->_oCacheObject = $this->_oQuery->getDbCacheObject();
+        $this->_sCacheKeySystems = 'sys_live_updates_systems';
+        $this->_sCacheKeyData = 'sys_live_updates_data_' . $this->_iProfileId;
+        $this->_iCacheTTL = 86400;
+
+        $this->_sJsClass = 'BxDolLiveUpdates';
+    	$this->_sJsObject = 'oLiveUpdates';
+
+    	$this->_aSystems = $this->_getCachedSystems();
+    	$this->_aSystemsActive = array_keys($this->_aSystems);
+    	$this->_aSystemsTransient = array();
+    }
+
+    public function __clone()
+    {
+        if (isset($GLOBALS['bxDolClasses'][get_class($this)]))
+            trigger_error('Clone is not allowed for the class: ' . get_class($this), E_USER_ERROR);
+    }
+
+    /**
+     * get live updates instanse
+     * @return ready to use class instance
+     */
+    public static function getInstance()
+    {
+        if(!isLogged())
+            return false;
+
+        if(!isset($GLOBALS['bxDolClasses'][__CLASS__]))
+            $GLOBALS['bxDolClasses'][__CLASS__] = new BxTemplLiveUpdates();
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    /**
+     * 
+     * Is called via Ajax Requests only. 
+     */
+    public function perform()
+    {
+        $iIndex = (int)bx_get('index');
+
+        $mixedCacheKey = bx_get('hash');
+        if($mixedCacheKey !== false)
+            $this->_sCacheKeySystems = $this->_decodeHash(bx_process_input($mixedCacheKey));
+
+        $mixedSystemsActive = bx_get('systems_active');
+        if($mixedSystemsActive !== false)
+            $this->_aSystemsActive = array_keys($mixedSystemsActive);
+
+        $mixedSystemsTransient = bx_get('systems_transient');
+        if($mixedSystemsTransient !== false)
+            $this->_aSystemsTransient = array_keys($mixedSystemsTransient);
+
+        $this->_aSystems = $this->_getCachedSystems();
+    	if(empty($this->_aSystems) || !is_array($this->_aSystems))
+            return array();
+
+        $aCurrent = $mixedSystemsActive;
+        if(empty($aCurrent) || !is_array($aCurrent))
+            $aCurrent = $this->_getCachedData();
+
+        $aRequested = $this->_getRequestedData($iIndex, true, $aCurrent);
+
+        $aResult = array();
+        $bUpdateCache = false;
+        foreach($aRequested as $sName => $aData) {
+            if(isset($aCurrent[$sName])) {
+                if((int)$aCurrent[$sName] == (int)$aData['count'])			    
+                    continue;
+
+                $aResultData = array('count_new' => (int)$aData['count'], 'count_old' => (int)$aCurrent[$sName]);
+                if(isset($aData['data']))
+                    $aResultData = array_merge($aResultData, $aData['data']);
+
+                $aResult[] = array(
+                    'system' => $sName, 
+                    'data' => $aResultData,
+                    'method' => $aData['method']
+                );
+            }
+
+            $aCurrent[$sName] = $aData['count'];
+            $bUpdateCache = true;
+        }
+
+        if($bUpdateCache)
+            $this->_updateCached('data', $aCurrent);
+
+    	return $aResult;
+    }
+    
+    
+    public function performApi()
+    {
+        $aRequested = $this->_getRequestedData(0, true);
+
+        $aResult = [];
+        foreach($aRequested as $sName => $aData) {
+            if(is_array($aData))
+                $aResult[$sName] = isset($aData['count']) ? (int)$aData['count'] : 0;
+            else
+                $aResult[$sName] = (int)$aData;
+        }
+
+    	return $aResult;
+    }
+
+    protected function _addSystem($sName, $iFrequency, $sServiceCall, $mixedActive = true)
+    {
+        if(!$this->_iProfileId)
+            return false;
+
+        if(empty($this->_aSystemsTransient))
+            $this->_sCacheKeySystems .= $this->_getPageId();
+
+        if(!in_array($sName, $this->_aSystemsTransient))
+            $this->_aSystemsTransient[] = $sName;
+
+        if(!in_array($sName, $this->_aSystemsActive))
+            $this->_aSystemsActive[] = $sName;
+
+        if(empty($this->_aSystems[$sName])) {
+            list($bActive, $bInit) = is_array($mixedActive) ? $mixedActive : [$mixedActive, true];
+            
+            $this->_aSystems[$sName] = [
+                'name' => $sName,
+                'init' => $bInit ? 1 : 0,
+                'frequency' => $iFrequency,
+                'service_call' => $sServiceCall,
+                'active' => $bActive ? 1 : 0
+            ];
+
+            $this->_updateCached('systems', $this->_aSystems);
+        }
+
+        return true;
+    }
+    
+    protected function _addData($sName, $iValue)
+    {
+        $aCachedData = $this->_getCachedData();
+        $aCachedData[$sName] = $iValue;
+        $this->_updateCached('data', $aCachedData);
+
+        return true;
+    }
+
+    protected function _getCacheInfo($sType)
+    {
+    	return array(
+            $this->_oCacheObject,
+            $this->_getCacheKey($sType),
+            $this->_iCacheTTL
+    	);
+    }
+
+    protected function _getCacheKey($sType)
+    {
+        return $this->_oQuery->genDbCacheKey($this->{'_sCacheKey' . ucfirst($sType)});
+    }
+
+    protected function _getCached($sType)
+    {
+        list($oCache, $sCacheKey, $iCacheTtl) = $this->_getCacheInfo($sType);
+
+        $aCached = $oCache->getData($sCacheKey, $iCacheTtl);
+        if(empty($aCached)) {
+            switch($sType) {
+                case 'systems':
+                    $aCached = $this->_oQuery->getSystems();
+                    break;
+
+                case 'data':
+                    if(empty($aCached))
+                        $aCached = array();
+
+                    $aRequested = $this->_getRequestedData();
+                    foreach($this->_aSystems as $sName => $aSystem)
+                        $aCached[$sName] = !empty($aRequested[$sName]['count']) ? (int)$aRequested[$sName]['count'] : 0;
+                    break;
+            }
+
+            if(!empty($aCached))
+                $oCache->setData($sCacheKey, $aCached, $iCacheTtl);
+        }
+
+        return $aCached;
+    }
+
+    protected function _getCachedSystems()
+    {
+        $aSystems = $this->_getCached('systems');
+        if(!empty($aSystems) && !empty($this->_aSystemsActive))
+            $aSystems = array_intersect_key($aSystems, array_flip($this->_aSystemsActive));
+
+        return $aSystems;
+    }
+
+    protected function _getCachedData($bInit = false)
+    {
+        $aData = $this->_getCached('data');
+        if($bInit)
+            foreach($this->_aSystems as $aSystem)
+                if(isset($aData[$aSystem['name']]) && (int)$aSystem['init'] == 0)
+                    $aData[$aSystem['name']] = 0;
+
+    	return $aData;
+    }
+
+    protected function _clearCached()
+    {
+        $this->_oCacheObject->delData($this->_getCacheKey('systems'));
+        $this->_oCacheObject->delData($this->_getCacheKey('data'));
+    }
+
+    protected function _updateCached($sType, $aData)
+    {
+    	list($oCache, $sCacheKey, $iCacheTtl) = $this->_getCacheInfo($sType);
+
+    	$aCached = $oCache->getData($sCacheKey, $iCacheTtl);
+        $aCached = array_merge($aCached ? $aCached : array(), $aData);
+
+    	$oCache->setData($sCacheKey, $aCached, $iCacheTtl);
+    }
+
+    protected function _getRequestedData($iIndex = 0, $bIndexCheck = false, $aCachedData = array())
+    {
+        $bInit = !$bIndexCheck && empty($aCachedData);
+
+        $aResult = array();
+        foreach($this->_aSystems as $sName => $aSystem) {
+            if(empty($aSystem) || !is_array($aSystem) || (int)$aSystem['active'] != 1 || ($bInit && (int)$aSystem['init'] == 0))
+                continue;
+
+            if($bIndexCheck && $iIndex % (int)$aSystem['frequency'] != 0)
+                continue;
+
+            $bCachedDataBySystem = !empty($aCachedData) && isset($aCachedData[$sName]);
+            $mixedResponce = $this->_getRequestedDataBySystem($aSystem, ($bCachedDataBySystem ? (int)$aCachedData[$sName] : 0), !$bCachedDataBySystem);
+            if($mixedResponce === false)
+                continue;
+
+            $aResult[$sName] = $mixedResponce;
+        }
+
+        return $aResult;
+    }
+
+    protected function _getRequestedDataBySystem($aSystem, $iCachedData = 0, $bInit = true)
+    {
+        if(!BxDolService::isSerializedService($aSystem['service_call']))
+            return false;
+
+        $aResponce = BxDolService::callSerialized($aSystem['service_call'], array('count' => (int)$iCachedData, 'init' => ($bInit ? 1 : 0)));
+        if(empty($aResponce) || !is_array($aResponce) || !isset($aResponce['count']) || (!$bInit && !isset($aResponce['method'])))
+            return false;
+
+        return $aResponce;
+    }
+
+    protected function _getPageId()
+    {
+        $aPageParams = array();
+        parse_str($_SERVER['QUERY_STRING'], $aPageParams);
+        $aPageParams = array_diff_assoc($aPageParams, array('start', 'per_page', 'order', 'filter'));
+
+        return '_' . $this->_iProfileId . '_' . md5(bx_append_url_params($_SERVER['PHP_SELF'], $aPageParams));
+    }
+
+    protected function _encodeHash()
+    {
+        return base64_encode($this->_sCacheKeySystems);
+    }
+
+    protected function _decodeHash($sHash)
+    {
+        return base64_decode($sHash);
+    }
+}
+
+/** @} */

+ 545 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolMenu.php

@@ -0,0 +1,545 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+/**
+ * Menus.
+ *
+ * Menu uses some set of items and the template to display it,
+ * so it is possible to have several menus which uses the same set of items but different templates.
+ *
+ * Menu is any set of some links or actions, for example menu can be links in site's footer or actions in profile view.
+ *
+ *
+ * @section menu_create Creating the Menu object:
+ *
+ * 1. Add record to 'sys_objects_menu' table:
+ *
+ * - object: name of the menu object, in the format: vendor prefix, underscore, module prefix, underscore, internal identifier or nothing; for example: bx_groups_actions - actions menu in group view.
+ * - title: name of the menu, displayed in the studio menu builder.
+ * - set_name: name of items' set.
+ * - module: the module this menu belongs to.
+ * - template_id: the template to use for menu displaying, this is id from 'sys_menu_templates' table.
+ * - deletable: it determines if menu can be deleted from the studio menu builder.
+ * - active: it is possible to disable particular menu, then it will not be displayed.
+ * - override_class_name: user defined class name which is derived from BxTemplMenu.
+ * - override_class_file: the location of the user defined class, leave it empty if class is located in system folders.
+ *
+ * Menu templates are stored in 'sys_menu_templates' table:
+ * - id: template id.
+ * - template: template file.
+ * - title: template title to display in the studio menu builder.
+ * All menu templates iterate through 'bx_repeat:menu_items' and use the following template variables for each menu item: __link__, __target__, __onclick__, __title__, __class_add__.
+ *
+ *
+ * 2. Add menu an empty menu set to 'sys_menu_sets' table (if you want to use new set of items for created menu):
+ * - set_name: the set name.
+ * - module: the module this set belongs to.
+ * - title: name of the set, displayed in studio menu builder.
+ * - deletable: it determines if the set can be deleted from menu builder.
+ *
+ *
+ * 3. Add menu items to the set by adding records to 'sys_menu_items' table:
+ * - set_name: the set name this item belogs to.
+ * - module: the module this item belongs to.
+ * - name: name of the item (not displayed to the end user), unique in the particular set.
+ * - title: menu item title to display to the end user, please note that some templates can still display menu as icons without text titles.
+ * - link: menu item URL.
+ * - onclick: menu item onclick event.
+ * - target: menu item target.
+ * - icon: menu item icon, please note that some templates can still display menu as text without icons.
+ * - addon: display additional data near menu item, only for supported menu templates, this is serialized array of service call parameters: module - module name, method - service method name, params - array of parameters.
+ * - markers: service method to provide additional replacement markers, this is serialized array of service call parameters: module - module name, method - service method name, params - array of parameters.
+ * - visible_for_levels: bit field with set of member level ids. To use member level id in bit field - the level id minus 1 as power of 2 is used, for example:
+ *      - user level id = 1 -> 2^(1-1) = 1
+ *      - user level id = 2 -> 2^(2-1) = 2
+ *      - user level id = 3 -> 2^(3-1) = 4
+ *      - user level id = 4 -> 2^(4-1) = 8
+ * - active: it is possible to disable particular menu item, then it will not be displayed.
+ * - order: menu item order in the particular set.
+ *
+ *
+ * 4. Display Menu.
+ * Use the following sample code to display menu:
+ * @code
+ *     $oMenu = BxTemplMenu::getObjectInstance('sample_menu'); // 'sample_menu' is 'object' field from 'sys_objects_menu' table.
+ *     if ($oMenu)
+ *         echo $oMenu->getCode; // display menu
+ * @endcode
+ *
+ * But in most cases you don't need to use above code to display menu,
+ * menu objects are integrated into pages - there is special 'menu' page block type for it.
+ *
+ */
+class BxDolMenu extends BxDolFactory implements iBxDolFactoryObject, iBxDolReplaceable
+{
+    protected static $SEL_MODULE = '';
+    protected static $SEL_NAME = '';
+
+    protected $_bIsApi;
+
+    protected $_bHx;
+    protected $_bHxHead;
+    protected $_mHxPreload;
+    protected $_aHx;
+
+    protected $_bDynamicMode;
+    protected $_bAddNoFollow;
+
+    protected $_bSelModuleCheck;
+    protected $_sSelModule;
+    protected $_sSelName;
+
+    protected $_sObject;
+    protected $_aObject;
+    protected $_oQuery;
+    protected $_oPermalinks;
+    protected $_aMarkers = array();
+    protected $_bMultilevel = false;
+
+    protected $_sSessionKeyCollapsed;
+
+    protected $_aContentParams;
+
+    /**
+     * Constructor
+     * @param $aObject array of menu options
+     */
+    protected function __construct($aObject)
+    {
+        parent::__construct();
+
+        $this->_bIsApi = bx_is_api();
+
+        $this->_bHx = false;
+        $this->_aHx = [];
+
+        $this->_bDynamicMode = false;
+        $this->_bAddNoFollow = getParam('sys_add_nofollow') == 'on';
+
+        $this->_bSelModuleCheck = false;
+
+        $this->_sObject = isset($aObject['object']) ? $aObject['object'] : 'bx-menu-obj-' . time() . rand(0, PHP_INT_MAX);
+        $this->_aObject = $aObject;
+        $this->_oQuery = new BxDolMenuQuery($this->_aObject);
+        $this->_oPermalinks = BxDolPermalinks::getInstance();
+
+        $this->_bMultilevel = !empty($this->_aObject['set_name']) && $this->_oQuery->isSetMultilevel($this->_aObject['set_name']);
+
+        $this->_sSessionKeyCollapsed = 'bx_menu_collapsed_';
+
+        $this->_aContentParams = [];
+
+        $this->addMarkers([
+            'object' => $this->_sObject
+        ]);
+
+        if(isLogged() && ($oProfile = BxDolProfile::getInstance()) !== false) {
+            $sUrl = $oProfile->getUrl();
+            if($this->_bIsApi)
+                $sUrl = bx_api_get_relative_url($sUrl);
+
+            $this->addMarkers([
+                'member_id' => $oProfile->id(),
+                'member_display_name' => $oProfile->getDisplayName(),
+                'member_url' => $sUrl,
+                'content_id' => $oProfile->getContentId()
+            ]);
+        }
+    }
+
+    /**
+     * Get menu object instance by object name
+     * @param $sObject object name
+     * @return object instance or false on error
+     */
+    static public function getObjectInstance($sObject, $oTemplate = false)
+    {
+        $oMenu = false;
+        if (!isset($GLOBALS['bxDolClasses']['BxDolMenu!'.$sObject])) {
+            $aObject = BxDolMenuQuery::getMenuObject($sObject);
+            if (!$aObject || !is_array($aObject) || (int)$aObject['active'] == 0)
+                return false;
+
+            $sClass = 'BxTemplMenu';
+            if (!empty($aObject['override_class_name'])) {
+                $sClass = $aObject['override_class_name'];
+                if (!empty($aObject['override_class_file']))
+                    require_once(BX_DIRECTORY_PATH_ROOT . $aObject['override_class_file']);
+            }
+
+            $oMenu = new $sClass($aObject, $oTemplate);
+            $GLOBALS['bxDolClasses']['BxDolMenu!'.$sObject] = $oMenu;
+        }
+        else
+            $oMenu = $GLOBALS['bxDolClasses']['BxDolMenu!'.$sObject];
+
+        /**
+         * @hooks
+         * @hookdef hook-system-get_object 'system', 'get_object' - hook to override menu object
+         * - $unit_name - equals `system`
+         * - $action - equals `get_object`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `type` - [string] object type, equals to 'menu'
+         *      - `name` - [string] menu object name
+         *      - `object` - [object] by ref, an instance of menu, @see BxDolMenu, can be overridden in hook processing
+         * @hook @ref hook-system-get_object
+         */
+        bx_alert('system', 'get_object', 0, false, [
+            'type' => 'menu',
+            'name' => $sObject,
+            'object' => &$oMenu,
+        ]);
+
+        return $oMenu;
+    }
+
+    /**
+     * Set selected menu item globally.
+     * @param $sModule menu item module to set as selected
+     * @param $sName menu item name to set as selected
+     */
+    static public function setSelectedGlobal ($sModule, $sName)
+    {
+        self::$SEL_MODULE = $sModule;
+        self::$SEL_NAME = $sName;
+    }
+
+    /**
+     * Process menu triggers.
+     * Menu triggers allow to automatically add menu items to modules with no different if dependant module was install before or after the module menu item belongs to.
+     * For example module "Notes" adds menu items to all profiles modules (Persons, Organizations, etc)
+     * with no difference if persons module was installed before or after "Notes" module was installed.
+     * @param $sMenuTriggerName trigger name to process, usually specified in module installer class - @see BxBaseModGeneralInstaller
+     * @return always true, always success
+     */
+    static public function processMenuTrigger ($sMenuTriggerName)
+    {
+        // get list of active modules
+        $aModules = BxDolModuleQuery::getInstance()->getModulesBy(array(
+            'type' => 'modules',
+            'active' => 1,
+        ));
+
+        // get list of menu triggers
+        $aMenuItems = BxDolMenuQuery::getMenuTriggers($sMenuTriggerName);
+
+        // check each menu item trigger for all modules
+        foreach ($aMenuItems as $aMenuItem) {
+            foreach ($aModules as $aModule) {
+                if (!BxDolRequest::serviceExists($aModule['name'], 'get_menu_set_name_for_menu_trigger'))
+                    continue;
+
+                $mixedMenuSet = BxDolService::call($aModule['name'], 'get_menu_set_name_for_menu_trigger', array($sMenuTriggerName));
+                if(empty($mixedMenuSet))
+                    continue;
+
+                if(is_string($mixedMenuSet))
+                    $mixedMenuSet = array($mixedMenuSet);
+
+                foreach($mixedMenuSet as $sMenuSet) {
+                    if(empty($sMenuSet))
+                        continue;
+
+                    $aMenuItem['set_name'] = $sMenuSet;
+                    BxDolMenuQuery::addMenuItemToSet($aMenuItem);
+                }
+            }
+        }
+
+        return true;
+    }
+
+    public function isHtmx()
+    {
+        return $this->_bHx;
+    }
+
+    /**
+     * Check if the menu is visible. The menu is visible if at least one menu item is visible.
+     * @return boolean
+     */
+    public function isVisible()
+    {
+        if((int)$this->_aObject['active'] == 0)
+            return false;
+
+    	if(!isset($this->_aObject['menu_items']))
+            $this->_aObject['menu_items'] = $this->_oQuery->getMenuItems();
+
+    	$bVisible = false;
+    	foreach ($this->_aObject['menu_items'] as $a) {
+            if(!$this->_isActive($a) || !$this->_isVisible($a))
+                continue;
+
+            $bVisible = true;
+            break;
+    	}
+
+    	return $bVisible;
+    }
+
+    public function getQueryObject()
+    {
+        return $this->_oQuery;
+    }
+
+    public function getTemplateId()
+    {
+        return $this->_aObject['template_id'];
+    }
+
+    /**
+     * Get template name with checking for custom template related to exactly this menu object.
+     * @return string with template name.
+     */
+    public function getTemplateName($sName = '')
+    {
+        if(empty($sName))
+            $sName = $this->_aObject['template'];
+
+        $sNameCustom = str_replace('.html', '_' . $this->_sObject . '.html', $sName);
+        return $this->_oTemplate->isHtml($sNameCustom) ? $sNameCustom : $sName;
+    }
+
+    public function setTemplateById ($iTemplateId)
+    {
+        $aTemplate = $this->_oQuery->getMenuTemplateById($iTemplateId);
+        if(empty($aTemplate) || !is_array($aTemplate))
+            return;
+
+        $this->_aObject['template'] = $aTemplate['template'];
+    }
+    
+    /**
+     * Set selected menu item for current menu object only.
+     * @param $sModule menu item module to set as selected
+     * @param $sName menu item name to set as selected
+     */
+    public function setSelected ($sModule, $sName)
+    {
+        $this->_sSelModule = $sModule;
+        $this->_sSelName = $sName;
+    }
+
+    public function setDynamicMode ($bDynamicMode)
+    {
+        $this->_bDynamicMode = $bDynamicMode;
+    }
+
+    public function setHtmx($bHx)
+    {
+        $this->_bHx = $bHx;
+    }
+
+    /**
+     * Get an arrey of replacable markers.
+     * @return an array with markers
+     */
+    public function getMarkers()
+    {
+        return $this->_aMarkers;
+    }
+    
+    /**
+     * Add replace markers.
+     * @param $a array of markers as key => value
+     * @return true on success or false on error
+     */
+    public function addMarkers ($a)
+    {
+        if (empty($a) || !is_array($a))
+            return false;
+        $this->_aMarkers = array_merge ($this->_aMarkers, $a);
+        return true;
+    }
+
+    /**
+     * Remove marker
+     * @param $s marker key
+     */
+    public function removeMarker ($s) 
+    {
+        unset($this->_aMarkers[$s]);
+    }
+
+    public function initContentParams()
+    {
+        $this->_aContentParams = [];
+    }
+
+    public function setContentParams($aParams)
+    {
+        $this->_aContentParams = $aParams;
+
+        return true;
+    }
+
+    public function getContentParams()
+    {
+        if(!$this->_aContentParams)
+            $this->initContentParams();
+
+        return $this->_aContentParams;
+    }
+
+    public function performActionSetCollapsed($mixedValue)
+    {
+        $this->_setCollapsed($this->_sObject, (int)$mixedValue);
+    }
+
+    public function performActionSetCollapsedSubmenu($sMenuItem, $mixedValue)
+    {
+        $this->_setCollapsed($this->_sObject . '_' . $sMenuItem, (int)$mixedValue);
+    }
+
+    public function getUserChoiceCollapsed($sObject = '')
+    {
+        $iProfile = bx_get_logged_profile_id();
+        if(!$iProfile)
+            return false;
+
+        if(!$sObject)
+            $sObject = $this->_sObject;
+
+        $sSessionKey = $this->_sSessionKeyCollapsed . $iProfile;
+        $aCollapsed = BxDolSession::getInstance()->getValue($sSessionKey);
+        if(!isset($aCollapsed[$sObject]))
+            return false;
+
+        return (int)$aCollapsed[$sObject];
+    }
+
+    public function getUserChoiceCollapsedSubmenu($mixedItem, $sObject = '')
+    {
+        if(!$sObject)
+            $sObject = $this->_sObject;
+
+        if(is_array($mixedItem) && isset($mixedItem['name']))
+            $sObject .= '_' . $mixedItem['name'];
+        else if(is_string($mixedItem))
+            $sObject .= '_' . $mixedItem;
+
+        return $this->getUserChoiceCollapsed($sObject);
+    }
+
+    protected function _setCollapsed($sName, $mixedValue)
+    {
+        $iProfile = bx_get_logged_profile_id();
+        if(!$iProfile)
+            return;
+
+        $oSession = BxDolSession::getInstance();
+        $sSessionKey = $this->_sSessionKeyCollapsed . $iProfile;
+
+        $aCollapsed = $oSession->getValue($sSessionKey);
+        if(!is_array($aCollapsed))
+            $aCollapsed = [];
+
+        $aCollapsed[$sName] = $mixedValue;
+        $oSession->setValue($sSessionKey, $aCollapsed);
+    }
+
+    /**
+     * Check if menu items is selected.
+     * @param $a menu item array
+     * @return boolean
+     */
+    protected function _isSelected ($a)
+    {
+        if($this->_sSelModule || $this->_sSelName)
+            return (!$this->_bSelModuleCheck || !isset($a['module']) || $a['module'] == $this->_sSelModule) && (isset($a['name']) && $a['name'] == $this->_sSelName) ? true : false;
+
+        return (!$this->_bSelModuleCheck || !isset($a['module']) || $a['module'] == self::$SEL_MODULE) && (isset($a['name']) && $a['name'] == self::$SEL_NAME) ? true : false;
+    }
+
+    /**
+     * Check if menu items is active.
+     * @param $a menu item array
+     * @return boolean
+     */
+    protected function _isActive ($a)
+    {
+        if($this->_bIsApi)
+            return !isset($a['active_api']) || (int)$a['active_api'] !=0;
+        else
+            return !isset($a['active']) || (int)$a['active'] != 0;
+    }
+
+    /**
+     * Check if menu items is visible.
+     * @param $a menu item array
+     * @return boolean
+     */
+    protected function _isVisible ($a)
+    {
+        if(isset($a['visible_for_levels']) && !BxDolAcl::getInstance()->isMemberLevelInSet($a['visible_for_levels']))
+            return false;
+
+        if(!empty($a['visibility_custom']) && !BxDolService::callSerialized($a['visibility_custom'], $this->_aMarkers))
+            return false;
+
+        if($this->_iPageType && !empty($a['hidden_on_pt']) && ((1 << ($this->_iPageType - 2)) & (int)$a['hidden_on_pt']))
+            return false;
+
+        return true;
+    }
+    
+    protected function _getVisibilityClass($a)
+    {
+        $aHiddenOn = array(
+            pow(2, BX_DB_HIDDEN_PHONE - 1) => 'bx-def-media-phone-hide',
+            pow(2, BX_DB_HIDDEN_TABLET - 1) => 'bx-def-media-tablet-hide',
+            pow(2, BX_DB_HIDDEN_DESKTOP - 1) => 'bx-def-media-desktop-hide',
+            pow(2, BX_DB_HIDDEN_MOBILE - 1) => 'bx-def-mobile-app-hide'
+        );
+        
+        $aHiddenOnCol = array(
+            pow(2, 1) => 'bx-def-thin-col-hide',
+            pow(2, 2) => 'bx-def-half-col-hide',
+            pow(2, 3) => 'bx-def-wide-col-hide',
+            pow(2, 4) => 'bx-def-full-col-hide'
+        );
+        
+        $sHiddenOnCssClasses = '';
+        if(!empty($a['hidden_on']))
+            foreach($aHiddenOn as $iHiddenOn => $sClass)
+                if((int)$a['hidden_on'] & $iHiddenOn)
+                    $sHiddenOnCssClasses .= ' ' . $sClass;
+        
+        
+        if(!empty($a['hidden_on_col'])){    
+            foreach($aHiddenOnCol as $iHiddenOn => $sClass)
+                if((int)$a['hidden_on_col'] & $iHiddenOn)
+                    $sHiddenOnCssClasses .= ' ' . $sClass;
+        }
+        
+        return $sHiddenOnCssClasses;
+    }
+
+    /**
+     * Replace provided markers in menu item array, curently markers are replaced in title, link and onclick fields.
+     * @param $a menu item array
+     * @return array where markes are replaced with real values
+     */
+    protected function _replaceMarkers ($a)
+    {
+        if (empty($this->_aMarkers))
+            return $a;
+        $aReplacebleFields = array ('title', 'link', 'onclick');
+        foreach ($aReplacebleFields as $sField)
+            if (isset($a[$sField]))
+                $a[$sField] = bx_replace_markers($a[$sField], $this->_aMarkers);
+        return $a;
+    }
+
+}
+
+/** @} */

+ 1221 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolMetatags.php

@@ -0,0 +1,1221 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+/**
+ * Meta-tags for different content. It can handle \#keywords, \@mentions, locations and meta image for the content.
+ *
+ * Keywords are parsed after content add and edit and added to the database, 
+ * later when content is displayed \#keywords are highlighted as links to the search page with all content with this keyword, 
+ * also keywords are displayed as meta info in page header.
+ * 
+ * Location info upon content adding/editing is displayed as custom field, it detects current user location and attach location as hidden form fields.
+ * When content is displayed location can be shown as clickable links to search page where results are other items from same location,
+ * also location is displayed as meta info in page header as latutude and longitude.
+ * 
+ * Content image can be added as meta info using this class as well.
+ *
+ *
+ *
+ * @section metatags_create Add new meta-tags object.
+ *
+ * To add new meta-tags object insert new record into `sys_objects_metatags` table:
+ * - object: name of the meta-tags object, in the format: vendor prefix, underscore, module prefix, underscore, internal identifier or nothing
+ * - table_keywords: table name to store keywords, leave empty to disable keywords
+ * - table_locations: table name to store locations, leave empty to disable locations
+ * - table_mentions: table name to store mentions, leave empty to disable mentions
+ * - override_class_name: class name to override  
+ * - override_class_file: class file path to override
+ *
+ * To simplify operations with metatags in modules which are derived from some 'base' modules, specify it in module config class in 'CNF' array: 
+ * - FIELDS_WITH_KEYWORDS: array or comma separated list of field names with keywords support, or 'auto' to automatically use all 'textarea' fields
+ * - OBJECT_METATAGS: metatags object name
+ *
+ *
+ *
+ * @section metatags_locations Location
+ *
+ * Upon content 'add' and 'edit' form submit, call BxDolMetatags::locationsAddFromForm
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $oMetatags->locationsAddFromForm($iContentId);
+ * @endcode
+ *
+ * Upon 'edit' form display call BxDolMetatags::locationGet to fill-in location form input, for example:
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $aSpecificValues = $oMetatags->locationGet($iContentId);
+ *   $oForm->initChecker($aContentInfo, $aSpecificValues);
+ * @endcode
+ *
+ * To display location call BxDolMetatags::locationsString:
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   echo $oMetatags->locationsString($iContentId)
+ * @endcode
+ *
+ * Upon page display call BxDolMetatags::addPageMetaInfo to add location (and all other, including content image) meta information to the page header:
+ * @code
+ *   $o = BxDolMetatags::getObjectInstance('object_name');
+ *   $o->addPageMetaInfo($iContentId, array('id' => $iFileId, 'object' => 'storage_object_name'));
+ * @endcode
+ * 
+ * Form object must have location field, usual name for this field is 'location' (this name is used as $sPrefix in some functions), type is 'custom', 
+ * also form object must be derived from BxBaseModGeneralFormEntry class, which has custom field declaration.
+ *
+ *
+ *
+ * @section metatags_keywords \#keyword
+ *
+ * Upon content 'add' and 'edit' form submit, call BxDolMetatags::keywordsAdd
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $oMetatags->keywordsAdd($iContentId, $aContentInfo['text_field_with_hash_tags']);
+ * @endcode
+ *
+ * Before displaying the text with hashtags call BxDolMetatags::keywordsParse to highlight keywords:
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $sText = $oMetatags->keywordsParse($iContentId, $sText);
+ * @endcode
+ *
+ * Upon page display call BxDolMetatags::addPageMetaInfo to add keywords (and all other, including content image) meta information to the page header.
+ *
+ * To be able to search by tags metatgs object must be specified in *SearchResult class as 'object_metatags' in 'aCurrent' array.
+ *
+ *
+ *
+ * @section metatags_mention \@mention
+ * 
+ * Upon content 'add' and 'edit' form submit, call BxDolMetatags::mentionsAdd
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $oMetatags->mentionsAdd($iContentId, $aContentInfo['text_field_with_mentions']);
+ * @endcode
+ *
+ * Before displaying the text with mentions call BxDolMetatags::mentionsParse:
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $sText = $oMetatags->mentionsParse($iContentId, $sText);
+ * @endcode
+ *
+ * Upon page display call BxDolMetatags::addPageMetaInfo to add all available meta info to the page meta into where possible, however it doesn't adding anyting for mentions for now.
+ *
+ * To be able to search by mentions object must be specified in *SearchResult class as 'object_metatags' in 'aCurrent' array.
+ *
+ *
+ *
+ * @section metatags_auto Add \@mention, \#keyword using one call
+ * 
+ * Upon content 'add' and 'edit' form submit, call BxDolMetatags::metaAdd
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $oMetatags->metaAdd($iContentId, $aContentInfo['text_field']);
+ * @endcode
+ * or call BxDolMetatags::metaAddAuto, it autodetects fields with metainfo:
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $oMetatags->metaAddAuto($iContentId, $aContentInfo, $CNF, $CNF['OBJECT_FORM_ENTRY_DISPLAY_ADD']);
+ * @endcode
+ *
+ * Before displaying the text with different metainfo call BxDolMetatags::metaParse:
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $sText = $oMetatags->metaParse($iContentId, $sText);
+ * @endcode
+ *
+ * Upon page display call BxDolMetatags::addPageMetaInfo to add all available meta info to the page meta into where possible.
+ *
+ *
+ * 
+ * @section metatags_delete_content Content deletion
+ * 
+ * When content is deleted associated meta data can be deleted by calling BxDolMetatags::onDeleteContent:
+ * @code
+ *   $oMetatags = BxDolMetatags::getObjectInstance('object_name');
+ *   $oMetatags->onDeleteContent($iContentId);
+ * @endcode
+ *
+ */
+class BxDolMetatags extends BxDolFactory implements iBxDolFactoryObject
+{
+    protected static $_aLocationKeys = ['lat', 'lng', 'country', 'state', 'city', 'zip', 'street', 'street_number'];
+    protected static $_sKeywordPattern = '/[\pCc\pZ\p{Ps}\p{Pe}\p{Pi}\p{Pf}]\#(\pL[\pL\pN_]+)/u';
+            
+    protected $_sObject;
+    protected $_aObject;
+    protected $_oQuery;
+    protected $_aMetas = [];
+
+    /**
+     * Constructor
+     * @param $aObject array of metags object options
+     */
+    protected function __construct($aObject)
+    {
+        parent::__construct();
+
+        $this->_sObject = $aObject['object'];
+        $this->_aObject = $aObject;
+        
+        $a = array ('keywords', 'locations', 'mentions', 'pictures');
+        foreach ($a as $sMeta) {
+            if (empty($this->_aObject['table_' . $sMeta]))
+                continue;
+            $this->_aMetas[] = $sMeta;
+        }
+
+        $this->_oQuery = new BxDolMetatagsQuery($this->_aObject);
+    }
+
+    /**
+     * Get metatags object instance by object name
+     * @param $sObject object name
+     * @return object instance or false on error
+     */
+    static public function getObjectInstance($sObject)
+    {
+        if (isset($GLOBALS['bxDolClasses']['BxDolMetatags!'.$sObject])) 
+            return $GLOBALS['bxDolClasses']['BxDolMetatags!'.$sObject];
+
+        $aObject = BxDolMetatagsQuery::getMetatagsObject($sObject);
+        if (!$aObject || !is_array($aObject))
+            return false;
+
+        $sClass = 'BxTemplMetatags';
+        if (!empty($aObject['override_class_name']))
+            $sClass = $aObject['override_class_name'];
+        if (!empty($aObject['override_class_file']))
+            require_once(BX_DIRECTORY_PATH_ROOT . $aObject['override_class_file']);
+
+        $o = new $sClass($aObject);
+
+        return ($GLOBALS['bxDolClasses']['BxDolMetatags!'.$sObject] = $o);
+    }
+
+    /**
+     * Get metatags by term
+     * @param string $sMeta - 'keywords' is only supported meta for now
+     * @param type $sMetaItem
+     * @param type $sTerm
+     * @return type
+     */
+    static public function getMetatagsDataByTerm($sMeta, $sMetaItem, $sTerm)
+    {
+        $bHashtagsOnly = getParam('sys_metatags_hashtags_only') == 'on';
+        $aLabels = []; 
+        if($bHashtagsOnly)
+            $aLabels = array_map(function($sValue) {return mb_strtolower($sValue);}, BxDolLabel::getInstance()->getLabels(['type' => 'values']));
+
+        $aValues = [];
+        $aObjects = BxDolMetatagsQuery::getMetatagsObjects();
+        foreach($aObjects as $aObject) {
+            $oObject = BxDolMetatags::getObjectInstance($aObject['object']);
+            if(!$oObject || !$oObject->{$sMeta . 'IsEnabled'}())
+                continue;
+
+            $sMethod = $sMeta . 'GetByTerm';
+            if(!method_exists($oObject->_oQuery, $sMethod))
+                continue;
+
+            $aData = $oObject->_oQuery->$sMethod($sTerm);
+            foreach($aData as $aMeta) {
+                if($bHashtagsOnly && in_array(strtolower($aMeta[$sMetaItem]), $aLabels))
+                    continue;
+
+                $aMatch = [];
+                if(!preg_match(self::$_sKeywordPattern, ' #' . $aMeta[$sMetaItem], $aMatch) || !isset($aMatch[1]) || strcasecmp($aMeta[$sMetaItem], $aMatch[1]) != 0) 
+                    continue;
+
+                $aValues[$aMeta[$sMetaItem]] = [
+                    'id' => $aMeta['object_id'], 
+                    'meta' => $aMeta[$sMetaItem], 
+                    'url' => $oObject->keywordsGetHashTagUrl($aMeta[$sMetaItem], $aMeta['object_id'])
+                ];
+            }
+        }
+
+        $aValues = array_slice($aValues, 0, (int)getParam('sys_profiles_search_limit'));
+        
+        return $aValues;
+    }
+
+    public function getModule()
+    {
+        return $this->_aObject['module'];
+    }
+
+    /**
+     * Add all available meta tags to the head section 
+     * @return number of successfully added metas
+     */
+    public function addPageMetaInfo($iId, $mixedImage = false)
+    {
+        $i = 0;
+        foreach ($this->_aMetas as $sMeta) {
+            $sFunc = $sMeta . 'AddMeta';
+            $i += $this->$sFunc($iId);
+        }
+
+        if ($mixedImage && is_array($mixedImage)) {
+            
+            if (!empty($mixedImage['object']))
+                $o = BxDolStorage::getObjectInstance($mixedImage['object']);
+            elseif (!empty($mixedImage['transcoder']))
+                $o = BxDolTranscoder::getObjectInstance($mixedImage['transcoder']);
+
+            $mixedImage = $o ? $o->getFileUrlById($mixedImage['id']) : false;
+        }
+
+        if ($mixedImage) 
+            BxDolTemplate::getInstance()->addPageMetaImage($mixedImage);
+
+        return $i;
+    }
+
+    /**
+     * Perform @see keywordsParse @see mentionsParse and maybe other
+     * @param $iId content id
+     * @param $s string
+     * @return modified string
+     */    
+    public function metaParse($iId, $s)
+    {
+        foreach ($this->_aMetas as $sMeta) {
+            $sFunc = $sMeta . 'Parse';
+            if (method_exists($this, $sFunc))
+                $s = $this->$sFunc($iId, $s);
+        }
+        return $s;
+    }
+
+    /**
+     * Perform @see keywordsAddAuto @see mentionsAddAuto and maybe other
+     * @param $iId content id
+     * @param $aContentInfo content info array
+     * @param $CNF module config array
+     * @param $sFormDisplay form display object
+     * @return number of founded keywords, mentions, etc
+     */
+    public function metaAddAuto($iId, $aContentInfo, $CNF, $sFormDisplay) 
+    {
+        $i = 0;
+        foreach ($this->_aMetas as $sMeta) {
+            $sFunc = $sMeta . 'AddAuto';
+            if (method_exists($this, $sFunc))
+                $i += $this->$sFunc($iId, $aContentInfo, $CNF, $sFormDisplay);
+        }
+        return $i;
+    }
+
+    /**
+     * Perform @see keywordsAdd @see mentionsAdd and maybe other
+     * @param $iId content id
+     * @param $s string
+     * @return number of added keywords, mentions, etc
+     */
+    public function metaAdd($iId, $s)
+    {
+        $i = 0;
+        foreach ($this->_aMetas as $sMeta) {
+            if ('locations' == $sMeta)
+                continue;
+            $sFunc = $sMeta . 'Add';
+            if (method_exists($this, $sFunc))
+                $i += $this->$sFunc($iId, $s);
+        }
+        return $i;
+    }
+    
+    /**
+     * Checks if keywords enabled for current metatags object
+     */
+    public function keywordsIsEnabled() 
+    {
+        return empty($this->_aObject['table_keywords']) ? false : true;
+    }
+
+    /**
+     * This function is specifically for formatting "Photo Camera" string, when "Photo Camera" is used as image hashtag
+     */
+    static public function keywordsCameraModel($aExif) 
+    {
+        if (!isset($aExif['Make']))
+            return '';
+
+        $sMake = trim($aExif['Make']);
+        if ($sMake && isset($aExif['Model'])) {
+            $sModel = trim($aExif['Model']);
+            if (0 === mb_strpos($sModel, $sMake))
+                $sModel = mb_substr($sModel, mb_strlen($sMake));
+        }
+
+        return $sMake . (empty($sModel) ? '' : ' ' . trim($sModel));
+    }
+
+    /**
+     * Add \#keywords from the string
+     * @param $iId content id
+     * @param $s string with \#keywords
+     * @return number of found keywords
+     */
+    public function keywordsAdd($iId, $s) 
+    {
+        /**
+         * First of all remove <a> HTML tags which don't have 'bx-mention-link' class WITH their content.
+         * It's needed because hashtags cannot be used inside links, otherwise we'll get link inside link.
+         */
+        $s = preg_replace("/<a\b((?!bx-mention-link)[^>])*>(.*?)<\/a>/si", '', $s);
+
+        //--- Strip the other HTML tags.
+        $s = strip_tags(str_replace(array('<br>', '<br />', '<hr>', '<hr />', '</p>', '</h1>', '</h2>', '</h3>', '</h4>', '</h5>', '</h6>'), "\n", $s));
+
+        // process spaces
+        $s = str_ireplace('&nbsp;', ' ', $s);
+        
+        return $this->_metaAdd($iId, ' ' . $s, self::$_sKeywordPattern, 'keywordsDelete', 'keywordsAdd', 'keywordsGet', (int)getParam('sys_metatags_hashtags_max'), 'keyword');
+    }
+
+    /**
+     * Add keyword from the whole string
+     * @param $iId content id
+     * @param $s keyword 
+     * @return number of added keywords, should be 1
+     */
+    public function keywordsAddOne($iId, $s, $bDeletePreviousKeywords = true)
+    {
+        if ($iRet = $this->_oQuery->keywordsAdd($iId, array($s), $bDeletePreviousKeywords)) {
+            $sSource = $this->_sObject . '_' . $iId;
+
+            /**
+             * @hooks
+             * @hookdef hook-bx_dol_metatags-keyword_added '{object_name}', 'keyword_added' - hook after a keyword (hashtag) was recognized in provided text
+             * - $unit_name - metatags object name
+             * - $action - equals `keyword_added`
+             * - $object_id - content id
+             * - $sender_id - logged in profile id
+             * - $extra_params - array of additional params with the following array keys:
+             *      - `meta` - [string] recognized keyword
+             *      - `content_id` - [int] content id 
+             *      - `source` - [string] unique source id
+             * @hook @ref hook-bx_dol_metatags-keyword_added
+             */
+            bx_alert($this->_aObject['module'], 'keyword_added', $iId, bx_get_logged_profile_id(), [
+                'meta' => $s, 
+                'content_id' => $iId, 
+                'source' => $sSource
+            ]);
+
+            /**
+             * @hooks
+             * @hookdef hook-meta_keyword-added 'meta_keyword', 'added' - hook after a keyword (hashtag) was recognized in provided text
+             * It's equivalent to @ref hook-bx_dol_metatags-keyword_added 
+             * except `object` parameter with metatags object name was added in $extra_params
+             * @hook @ref hook-meta_keyword-added
+             */
+            bx_alert('meta_keyword', 'added', $iId, bx_get_logged_profile_id(), [
+                'meta' => $s, 
+                'content_id' => $iId, 
+                'object' => $this->_sObject, 
+                'source' => $sSource
+            ]);
+        }
+
+        return $iRet;
+    }
+
+    /**
+     * Add \#keywords from the content fields
+     * @param $iId content id
+     * @param $aContentInfo content info array
+     * @param $CNF module config array
+     * @param $sFormDisplay form display object
+     * @return number of found keywords
+     */
+    public function keywordsAddAuto($iId, $aContentInfo, $CNF, $sFormDisplay) 
+    {
+        $aFields = $this->metaFields($aContentInfo, $CNF, $sFormDisplay); 
+        $sTextWithKeywords = '';
+        foreach ($aFields as $sField)
+            $sTextWithKeywords .= "\n" . $aContentInfo[$sField];
+
+        return $this->keywordsAdd($iId, $sTextWithKeywords);
+    }
+
+    /**
+     * Get field names which are subject to parse keywords
+     */
+    public function metaFields($aContentInfo, $CNF, $sFormDisplay, $bHtmlOnly = false)
+    {
+        $aFields = array();
+        if (empty($CNF['FIELDS_WITH_KEYWORDS'])) {
+            return array();
+        }
+        elseif (is_string($CNF['FIELDS_WITH_KEYWORDS']) && 'auto' == $CNF['FIELDS_WITH_KEYWORDS']) {
+            if (!($oForm = BxDolForm::getObjectInstance($CNF['OBJECT_FORM_ENTRY'], $sFormDisplay)))
+                return array();
+
+            foreach ($oForm->aInputs as $k => $a) {
+                if ('textarea' == $a['type'] && (!$bHtmlOnly || ($bHtmlOnly && $a['html'])))
+                    $aFields[] = $a['name'];
+            }
+        } 
+        elseif (is_array($CNF['FIELDS_WITH_KEYWORDS'])) {
+            $aFields = $CNF['FIELDS_WITH_KEYWORDS'];
+        } 
+        elseif (is_string($CNF['FIELDS_WITH_KEYWORDS'])) {
+            $aFields = explode(',', $CNF['FIELDS_WITH_KEYWORDS']);
+        }
+
+        return $aFields;
+    }
+
+    /**
+     * Add links to the \#keywords in the string
+     * @param $iId content id
+     * @param $s string with \#keywords
+     * @return modified string where all \#keywords are transformed to links with rel="tag" attribute
+     */
+    public function keywordsParse($iId, $s) 
+    {   
+        $a = $this->keywordsGet($iId);
+        if (empty($a))
+            return $s;
+
+        /**
+         * Replace <a> tags, which have 'bx-mention-link' class, with their content. 
+         * It's needed to avoid link inside link after hashtags parsing.
+         */
+        $s = preg_replace('/<a\b[^>]*\bbx-mention-link\b[^>]*>(.*?)<\/a>/si', '$1', $s);
+
+        foreach ($a as $sKeyword) {
+            $f = function ($a) use ($sKeyword, $iId) {
+                return $a[1] . '<a class="bx-tag" rel="tag" href="' . $this->keywordsGetHashTagUrl($sKeyword, $iId) . '">' . (bx_is_api() ? '#' . $sKeyword : '<s>#</s><b>' . $sKeyword . '</b>') . '</a>';
+            };
+
+            $s = preg_replace_callback('/([^\pN^\pL])\#(' . preg_quote($sKeyword, '/') . ')/u', $f, $s);
+            $s = preg_replace_callback('/^()\#(' . preg_quote($sKeyword, '/') . ')/u', $f, $s);
+        }
+
+        return $s;
+    }
+
+    /**
+     * Add link to the provided keyword
+     * @param $iId content id
+     * @param $s keyword 
+     * @return modified string where provided keyword is transformed to link with rel="tag" attribute
+     */
+    public function keywordsParseOne($iId, $s) 
+    {   
+        $a = $this->keywordsGet($iId);
+        if (empty($a))
+            return $s;
+    
+        foreach ($a as $sKeyword)
+            if (0 === strcasecmp(mb_strtolower($s), mb_strtolower($sKeyword)))
+                $s = '<a class="bx-tag" rel="tag" href="' . $this->keywordsGetHashTagUrl($sKeyword, $iId) . '">' . $sKeyword . '</a>';
+
+        return $s;
+    }
+	
+    public function keywordsGetHashTagUrl($sKeyword, $iId, $mixedSection = false) 
+    {   
+        $sSectionPart = '';
+        if (!empty($mixedSection)) {
+            if (is_array($mixedSection))
+                $sSectionPart = '&section[]=' . implode('&section[]=', $mixedSection);
+            elseif (is_string($mixedSection))
+                $sSectionPart = '&section[]=' . $mixedSection;
+        }
+        
+        $sUrl = BX_DOL_URL_ROOT . 'searchKeyword.php?type=keyword&keyword=' . rawurlencode($sKeyword) . $sSectionPart;
+        
+        /**
+         * @hooks
+         * @hookdef hook-meta_keyword-url 'meta_keyword', 'url' - hook to override meta keyword URL
+         * - $unit_name - equals `meta_keyword`
+         * - $action - equals `url`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `url` - [string] by ref, keyword URL, can be overridden in hook processing
+         *      - `keyword` - [string] keyword
+         *      - `id` - [int] content id
+         *      - `object` - [string] metatags object name
+         *      - `section` - [string] a string with sections (modules) in which a search by keyword will be performed (for default hashtag URL)
+         * @hook @ref hook-meta_keyword-url
+         */
+        bx_alert('meta_keyword', 'url', 0, false, [
+            'url' => &$sUrl,
+            'keyword' => $sKeyword,
+            'id' => $iId,
+            'object' => $this->_sObject,
+            'section' => $mixedSection,
+            'sObject' => $this->_sObject, //depricated, will be removed in future versions, approximately in UNA 15.
+        ]);
+    
+        return bx_is_api() ? bx_api_get_relative_url($sUrl) : $sUrl;
+    }
+
+    /**
+     * Add keywords meta info to the head section
+     * @param $iId content id
+     */
+    protected function keywordsAddMeta($iId)
+    {
+        BxDolTemplate::getInstance()->addPageKeywords($this->keywordsGet($iId));
+    }
+
+    /**
+     * Get list of keywords associated with the content
+     * @return array of keywords
+     */
+    public function keywordsGet($iId)
+    {
+        return $this->_oQuery->keywordsGet($iId);
+    }
+
+    /**
+     * Set condition for search results object for meta keyword
+     * @param $oSearchResult search results object
+     * @param $sKeyword keyword
+     */
+    public function keywordsSetSearchCondition($oSearchResult, $sKeyword, $iCmtsSystemId = 0)
+    {
+        if (!$this->keywordsIsEnabled())
+            return;
+
+        if ('sys_cmts' == $oSearchResult->aCurrent['object_metatags']) {
+            $this->keywordsSetSearchConditionCmts($oSearchResult, $sKeyword, $iCmtsSystemId);
+            return;
+        }
+
+        $oSearchResult->aCurrent['restriction']['meta_keyword'] = array(
+            'value' => $sKeyword,
+            'field' => 'keyword',
+            'operator' => '=',
+            'table' => $this->_aObject['table_keywords'],
+        );
+
+        $oSearchResult->aCurrent['join']['meta_keyword'] = array(
+            'type' => 'INNER',
+            'table' => $this->_aObject['table_keywords'],
+            'mainField' => $oSearchResult->aCurrent['ident'],
+            'mainTable' => !empty($oSearchResult->aCurrent['tableSearch']) ? $oSearchResult->aCurrent['tableSearch'] : $oSearchResult->aCurrent['table'],
+            'onField' => 'object_id',
+            'joinFields' => array(),
+        );
+    }
+
+    public function keywordsSetSearchConditionCmts($oSearchResult, $sKeyword, $iCmtsSystemId = 0)
+    {
+        if (!$this->keywordsIsEnabled())
+            return;
+
+        if ($iCmtsSystemId) {
+            $oSearchResult->aCurrent['restriction']['meta_keyword_cmts_system'] = array(
+                'value' => $iCmtsSystemId,
+                'field' => 'system_id',
+                'operator' => '=',
+                'table' => 'sys_cmts_ids',
+            );
+        }
+        
+        $oSearchResult->aCurrent['restriction']['meta_keyword'] = array(
+            'value' => $sKeyword,
+            'field' => 'keyword',
+            'operator' => '=',
+            'table' => $this->_aObject['table_keywords'],
+        );
+
+        $oSearchResult->aCurrent['join']['meta_keyword_cmts'] = array(
+            'type' => 'INNER',
+            'table' => 'sys_cmts_ids',
+            'mainField' => $oSearchResult->aCurrent['ident'],
+            'mainTable' => !empty($oSearchResult->aCurrent['tableSearch']) ? $oSearchResult->aCurrent['tableSearch'] : $oSearchResult->aCurrent['table'],
+            'onField' => 'cmt_id',
+            'joinFields' => array(),
+        );
+        
+        $oSearchResult->aCurrent['join']['meta_keyword'] = array(
+            'type' => 'INNER',
+            'table' => $this->_aObject['table_keywords'],
+            'mainField' => 'id',
+            'mainTable' => 'sys_cmts_ids',
+            'onField' => 'object_id',
+            'joinFields' => array(),
+        );
+    }
+    
+	/**
+     * Get part of SQL query for meta keyword
+     * @param $sContentTable content table or alias
+     * @param $sContentField content table field or field alias
+     * @param $sKeyword keyword
+     */
+    public function keywordsGetAsSQLPart($sContentTable, $sContentField, $sKeyword)
+    {
+        if (empty($this->_aObject['table_keywords']))
+            return array();
+
+        return call_user_func_array(array($this->_oQuery, 'keywordsGetSQLParts'), func_get_args());
+    }
+
+    public function keywordsPopularList($iLimit, $mContextId = false)
+    {
+        return $this->_oQuery->keywordsPopularList($iLimit, $mContextId);
+    }
+
+
+    /**
+     * Checks if locations enabled for current metatags object
+     */
+    public function locationsIsEnabled() 
+    {
+        return empty($this->_aObject['table_locations']) ? false : true;
+    }
+
+    /**
+     * Add location for the content
+     * @param $iId content id
+     * @param $sLatitude latitude
+     * @param $sLongitude longitude
+     * @param $sCountryCode optional 2 letters country code (ISO 3166-1)
+     * @param $sState optional state/province/territory name
+     * @param $sCity optional city name
+     * @param $sZip optional ZIP/postcode
+     * @param $sStreet optional street name
+     * @param $sStreetNumber optional street number
+     * @return true if location was added, or false otherwise
+     */
+    public function locationsAdd($iId, $sLatitude, $sLongitude, $sCountryCode, $sState, $sCity, $sZip = '', $sStreet = '', $sStreetNumber = '') 
+    {
+        // TODO: if lat & lng aren't defined then perform geocoding automatically, or maybe leverage this on client side ?
+
+        return $this->_oQuery->locationsAdd($iId, $sLatitude, $sLongitude, $sCountryCode, $sState, $sCity, $sZip, $sStreet, $sStreetNumber);
+    }
+
+	/**
+     * Retrieve location for the content from POST data
+     * @param $sPrefix field prefix for POST data, or empty -  if no prefix
+     * @param $oForm form to use to get POST data, or null - then new form instance will be created
+     */
+    public static function locationsRetrieveFromForm($sPrefix = '', $oForm = null)
+    {
+        if($sPrefix)
+            $sPrefix .= '_';
+
+        if(!$oForm)
+            $oForm = new BxDolForm(array(), false);
+
+        $aResults = [];
+        foreach(self::$_aLocationKeys as $sKey) {
+            $sValue = $oForm->getCleanValue($sPrefix . $sKey);
+            if(!$sValue || $sValue == 'null')
+                $sValue = '';
+
+            $aResults[] = $sValue;
+        }
+
+        return $aResults;
+    }
+
+    /**
+     * Parse address components into associative array with the following indexes:
+     * lat, lng, country, state, city, zip, street, street_number
+     */ 
+    public static function locationsParseComponents($aAdress, $sPrefix = '')
+    {
+        if($sPrefix)
+            $sPrefix .= '_';
+
+        $aRet = array();
+        $iAdress = count($aAdress);
+        for($i = 0; $i < $iAdress; $i++)
+            if(isset(self::$_aLocationKeys[$i]))
+                $aRet[$sPrefix . self::$_aLocationKeys[$i]] = $aAdress[$i];
+
+        return $aRet;
+    }
+
+    /**
+     * Parse google's formatted address components into array with the following indexes:
+     * lat, lng, country, state, city, zip, street, street_number
+     */ 
+    public static function locationsParseAddressComponents($aAdress, $sPrefix = '')
+    {
+        if (!isset($aAdress['address_components']))
+            return false;
+        if (!isset($aAdress['geometry']['location']))
+            return false;
+
+        $aRet = array(
+            $sPrefix . 'lat' => $aAdress['geometry']['location']['lat'],
+            $sPrefix . 'lng' => $aAdress['geometry']['location']['lng'],
+        );
+
+        $aMap = array(
+            $sPrefix . 'city' => 'locality',
+            $sPrefix . 'state' => 'administrative_area_level_1',
+            $sPrefix . 'country' => 'country',
+            $sPrefix . 'zip' => 'postal_code',
+            $sPrefix . 'street' => 'route',
+            $sPrefix . 'street_number' => 'street_number'        
+        );
+
+        $aAdressComponents = $aAdress['address_components'];
+        
+        foreach ($aAdressComponents as $r) {
+            if (!isset($r['types']))
+                continue;
+            foreach ($aMap as $sKey => $sName) {
+                
+                if ('locality' == $sName && !in_array($sName, $r['types']))
+                    $sName = 'postal_town';
+                
+                if ('administrative_area_level_1' == $sName && !in_array($sName, $r['types']))
+                    $sName = 'administrative_area_level_2';
+
+                if (in_array($sName, $r['types'])) {
+                    $sIndex = 'route' == $sName || 'locality' == $sName ? 'long_name' : 'short_name';
+                    $aRet[$sKey] = $r[$sIndex];
+                }
+
+                if (in_array('locality', $r['types']))
+                    $aRet[$sPrefix . 'city'] = $r['short_name'];
+                else if (in_array('country', $r['types']))
+                    $aRet[$sPrefix . 'country'] = $r['short_name'];
+            }
+        }
+        
+        return $aRet;
+    }
+    
+    /**
+     * Add location for the content from POST data
+     * @param $iId content id
+     * @param $sPrefix field prefix for POST data, or empty -  if no prefix
+     * @param $oForm form to use to get POST data, or null - then new form instance will be created
+     */
+    public function locationsAddFromForm($iId, $sPrefix = '', $oForm = null)
+    {
+        if (!$this->locationsIsEnabled())
+            return;
+
+        call_user_func_array(array($this, 'locationsAdd'), array_merge(array($iId), self::locationsRetrieveFromForm($sPrefix, $oForm)));
+    }
+
+    /**
+     * Get locations string with links
+     * @param $iId content id
+     * @return string with links to country and city
+     */
+    public function locationsString($iId, $bHTML = true, $aParams = array())
+    {
+        bx_import('BxDolForm');        
+        $aLocation = $this->locationGet($iId);
+        return $this->locationsStringFromArray($aLocation, $bHTML, $aParams);
+    }
+
+    /**
+     * Get locations string with links
+     * @param $aLocation location array
+     * @return string with links to country and city
+     */
+    public function locationsStringFromArray($aLocation, $bHTML = true, $aParams = array())
+    {
+        $s = '';
+        $aCountries = BxDolFormQuery::getDataItems('Country');
+
+        if(!$aLocation || !$aLocation['country'] || !isset($aCountries[$aLocation['country']])) {
+            if (!empty($aLocation['lat']) || !empty($aLocation['lng']))
+                $s = _t('_sys_location_country', $aLocation['lat'] . ', ' . $aLocation['lng']);
+            else
+                return '';
+        }
+        
+        if (!$s) {
+            $sCountryUrl = '<a href="' . BX_DOL_URL_ROOT . 'searchKeyword.php?type=location_country&keyword=' . rawurlencode($aLocation['country']) . '">' . $aCountries[$aLocation['country']] . '</a>';
+            
+            if(empty($aLocation['state']) || empty($aLocation['city']) || (isset($aParams['country_only']) && $aParams['country_only']))
+                $s = _t('_sys_location_country', $sCountryUrl);
+        }
+
+        if (!$s) {
+            $sCityUrl = '<a href="' . BX_DOL_URL_ROOT . 'searchKeyword.php?type=location_country_city&keyword=' . $aLocation['country'] . '&state=' . rawurlencode($aLocation['state']) . '&city=' . rawurlencode($aLocation['city']) . '">' . bx_process_output($aLocation['city']) . '</a>';
+            $sStateUrl = '<a href="' . BX_DOL_URL_ROOT . 'searchKeyword.php?type=location_country_state&keyword=' . $aLocation['country'] . '&state=' . rawurlencode($aLocation['state']) . '">' . bx_process_output($aLocation['state']) . '</a>';
+
+            if (empty($aLocation['street']) || empty($aLocation['street_number']) || (isset($aParams['country_city_only']) && $aParams['country_city_only']))
+        	    $s = _t('_sys_location_country_city', $sCountryUrl, $sStateUrl, $sCityUrl);
+            elseif (!isset($aParams['country_city_only']) || !$aParams['country_city_only'])
+                $s = _t('_sys_location_country_city_street', $sCountryUrl, $sStateUrl, $sCityUrl, bx_process_output($aLocation['street']), bx_process_output($aLocation['street_number']));
+        }
+
+        return $bHTML ? $s : trim(strip_tags($s));
+    }
+
+    /**
+     * Add keywords meta info to the head section
+     * @param $iId content id
+     */
+    protected function locationsAddMeta($iId) 
+    {
+        $aLocation = $this->locationGet($iId);
+        if (!empty($aLocation['lat']) && !empty($aLocation['lng']) && !empty($aLocation['country']))
+            BxDolTemplate::getInstance()->addPageMetaLocation($aLocation['lat'], $aLocation['lng'], $aLocation['country']);
+    }
+
+    /**
+     * Set condition for search results object for meta locations
+     * @param $oSearchResult search results object
+     * @param $sCountry country and other location info
+     */
+    public function locationsSetSearchCondition($oSearchResult, $sCountry = false, $sState = false, $sCity = false, $sZip = false)
+    {
+        if (empty($this->_aObject['table_locations'])) {
+            $oSearchResult->aCurrent['restriction']['meta_location'] = array(
+                'operator' => 'nothing',
+                'field' => 'nofield',
+                'value' => 'novalue',
+            );
+            return;
+        }
+
+        $aIndexes = ['country' => 'sCountry', 'state' => 'sState', 'city' => 'sCity', 'zip' => 'sZip'];
+        $aOperators = ['city' => 'like'];
+
+        foreach ($aIndexes as $sIndex => $sVar) {
+            if (!$$sVar)
+                continue;
+
+            $oSearchResult->aCurrent['restriction']['meta_location_' . $sIndex] = [
+                'value' => $$sVar,
+                'field' => $sIndex,
+                'operator' => isset($aOperators[$sIndex]) ? $aOperators[$sIndex] : '=',
+                'table' => $this->_aObject['table_locations'],
+            ];
+        }
+
+        $oSearchResult->aCurrent['join']['meta_location'] = array(
+            'type' => 'INNER',
+            'table' => $this->_aObject['table_locations'],
+            'mainField' => $oSearchResult->aCurrent['ident'],
+            'mainTable' => !empty($oSearchResult->aCurrent['tableSearch']) ? $oSearchResult->aCurrent['tableSearch'] : $oSearchResult->aCurrent['table'],
+            'onField' => 'object_id',
+            'joinFields' => array(),
+        );
+    }
+
+    /**
+     * Get part of SQL query for meta locations
+     * @param $sContentTable content table or alias
+     * @param $sContentField content table field or field alias
+     * @param $sCountry country and other location info
+     */
+    public function locationsGetAsSQLPart($sContentTable, $sContentField, $sCountry = false, $sState = false, $sCity = false, $sZip = false, $aBounds = array())
+    {
+        if (empty($this->_aObject['table_locations']))
+            return array();
+
+        return call_user_func_array(array($this->_oQuery, 'locationsGetSQLParts'), func_get_args());
+    }
+
+    /**
+     * Get location
+     * @param $iId content id
+     * @param $sPrefix field prefix for returning data array
+     * @return location array with the following keys (when no prefix specified): object_id, lat, lng, country, state, city, zip, street, street_number
+     */
+    public function locationGet($iId, $sPrefix = '')
+    {
+        if (!$this->locationsIsEnabled())
+            return;
+
+        $a = $this->_oQuery->locationGet($iId);
+        if (!$sPrefix)
+            return $a;
+
+        $aRet = array();
+        foreach ($a as $sKey => $sVal)
+            $aRet[$sPrefix . '_' . $sKey] = $sVal;
+        return $aRet;
+    }
+
+    /**
+     * Checks if mentions enabled for current metatags object
+     */
+    public function mentionsIsEnabled() 
+    {
+        return empty($this->_aObject['table_mentions']) ? false : true;
+    }
+
+    /**
+     * Add \@mentions from the string (most probably \@mentions will be some sort of links already, so parsing may have to look for smth like <a data-mention="bx_persons|123">mention name</a> instead of \@mention, since there is no usernames for profiles modules and name could contain spaces and othr characters)
+     * @param $iId content id
+     * @param $s string with \@mentions
+     * @return number of found mentions 
+     */
+    public function mentionsAdd($iId, $s) 
+    {
+        return $this->_metaAdd($iId, $s, '/data\-profile\-id="([0-9a-zA-Z]+)"/u', 'mentionsDelete', 'mentionsAdd', 'mentionsGet', (int)getParam('sys_metatags_mentions_max'), 'mention');
+    }
+
+    /**
+     * Add \@mentions from the content of form fields
+     * @param $iId content id
+     * @param $aContentInfo content info array
+     * @param $CNF module config array
+     * @param $sFormDisplay form display object
+     * @return number of found keywords
+     */
+    public function mentionsAddAuto($iId, $aContentInfo, $CNF, $sFormDisplay) 
+    {
+        $aFields = $this->metaFields($aContentInfo, $CNF, $sFormDisplay, true); 
+        $sTextWithKeywords = '';
+        foreach ($aFields as $sField)
+            $sTextWithKeywords .= "\n" . $aContentInfo[$sField];
+
+        return $this->mentionsAdd($iId, $sTextWithKeywords);
+    }
+    
+    /**
+     * Add links to the \@mentions in the string (actual tranformation may have to be performed with ready links like <a data-mention="bx_persons|123">mention name</a>)
+     * @param $iId content id
+     * @param $s string with \@mentions
+     * @return modified string where all \@mentions are transformed to links
+     */
+    public function mentionsParse($iId, $s) 
+    {
+        if (!bx_is_api())
+            return $s;
+        
+        if(($sRootUrl = getParam('sys_api_url_root_email')) !== '') {
+            if(substr(BX_DOL_URL_ROOT, -1) == '/' && substr($sRootUrl, -1) != '/')
+                $sRootUrl .= '/';
+
+            $s = str_replace(BX_DOL_URL_ROOT, $sRootUrl, $s);
+        }
+        
+        return $s;
+    }
+
+    /**
+     * No mentions meta info in the head section
+     */
+    protected function mentionsAddMeta($iId) 
+    {
+        return 0;
+    }
+
+    /**
+     * Get list of profile IDs associated with the content
+     * @return array of profile IDs
+     */
+    public function mentionsGet($iId)
+    {
+        return $this->_oQuery->mentionsGet($iId);
+    }
+    
+    /**
+     * Set condition for search results object for mentions
+     * @param $oSearchResult search results object
+     * @param $sMention smbd
+     */
+    public function mentionsSetSearchCondition($oSearchResult, $iProfileId, $iCmtsSystemId = 0)
+    {
+        if (!$this->mentionsIsEnabled())
+            return;
+
+        if ('sys_cmts' == $oSearchResult->aCurrent['object_metatags']) {
+            $this->mentionsSetSearchConditionCmts($oSearchResult, $iProfileId, $iCmtsSystemId);
+            return;
+        }
+
+        $oSearchResult->aCurrent['restriction']['meta_mentions'] = array(
+            'value' => $iProfileId,
+            'field' => 'profile_id',
+            'operator' => '=',
+            'table' => $this->_aObject['table_mentions'],
+        );
+
+        $oSearchResult->aCurrent['join']['meta_mentions'] = array(
+            'type' => 'INNER',
+            'table' => $this->_aObject['table_mentions'],
+            'mainField' => $oSearchResult->aCurrent['ident'],
+            'mainTable' => !empty($oSearchResult->aCurrent['tableSearch']) ? $oSearchResult->aCurrent['tableSearch'] : $oSearchResult->aCurrent['table'],
+            'onField' => 'object_id',
+            'joinFields' => array(),
+        );
+    }
+
+    public function mentionsSetSearchConditionCmts($oSearchResult, $iProfileId, $iCmtsSystemId = 0)
+    {
+        if (!$this->keywordsIsEnabled())
+            return;
+
+        if ($iCmtsSystemId) {
+            $oSearchResult->aCurrent['restriction']['meta_mentions_cmts_system'] = array(
+                'value' => $iCmtsSystemId,
+                'field' => 'system_id',
+                'operator' => '=',
+                'table' => 'sys_cmts_ids',
+            );
+        }
+        
+        $oSearchResult->aCurrent['restriction']['meta_mentions'] = array(
+            'value' => $iProfileId,
+            'field' => 'profile_id',
+            'operator' => '=',
+            'table' => $this->_aObject['table_mentions'],
+        );
+
+        $oSearchResult->aCurrent['join']['meta_mentions_cmts'] = array(
+            'type' => 'INNER',
+            'table' => 'sys_cmts_ids',
+            'mainField' => $oSearchResult->aCurrent['ident'],
+            'mainTable' => !empty($oSearchResult->aCurrent['tableSearch']) ? $oSearchResult->aCurrent['tableSearch'] : $oSearchResult->aCurrent['table'],
+            'onField' => 'cmt_id',
+            'joinFields' => array(),
+        );
+        
+        $oSearchResult->aCurrent['join']['meta_mentions'] = array(
+            'type' => 'INNER',
+            'table' => $this->_aObject['table_mentions'],
+            'mainField' => 'id',
+            'mainTable' => 'sys_cmts_ids',
+            'onField' => 'object_id',
+            'joinFields' => array(),
+        );
+    }
+    
+    /**
+     * Delete all data associated with the content
+     * @param $iId content id
+     */
+    public function onDeleteContent($iId) 
+    {
+        $i = 0;
+        foreach ($this->_aMetas as $sMeta) {
+            $sFunc = $sMeta . 'Delete';
+            $i += $this->_oQuery->$sFunc($iId);
+        }
+        return $i;
+    }
+
+    protected function _metaAdd($iId, $s, $sPreg, $sFuncDelete, $sFuncAdd, $sFuncGet, $iMaxItems, $sAlertName) 
+    {
+        $a = array();
+        if (!preg_match_all($sPreg, $s, $a)) {
+            $this->_oQuery->$sFuncDelete($iId);
+            return 0;
+        }
+
+        $aMetas = array_unique($a[1]);
+        $aMetas = array_slice($aMetas, 0, $iMaxItems);
+
+        // check if keywords/mentions were changed
+        $aMetasOld = $this->$sFuncGet($iId);
+        if (is_array($aMetas) && is_array($aMetasOld) 
+            && count($aMetas) == count($aMetasOld) 
+            && empty(array_diff($aMetas, $aMetasOld))
+            && empty(array_diff($aMetasOld, $aMetas))
+        ) {
+            return 0;
+        }
+
+        if ($iRet = $this->_oQuery->$sFuncAdd($iId, $aMetas)) {
+            foreach ($aMetas as $sMeta) {
+                $iObjectId = 'mention' == $sAlertName ? $sMeta : $iId;
+                
+                /**
+                 * @hooks
+                 * @hookdef hook-meta_keyword-before_added 'meta_keyword', 'before_added' - hook to override meta keyword before it will be processed
+                 * - $unit_name - equals `meta_keyword`
+                 * - $action - equals `before_added`
+                 * - $object_id - object id
+                 * - $sender_id - currently logged in profile id
+                 * - $extra_params - array of additional params with the following array keys:
+                 *      - `meta` - [string] by ref, keyword, can be overridden in hook processing
+                 *      - `content_id` - [int] content id
+                 *      - `object` - [string] metatags object name
+                 * @hook @ref hook-meta_keyword-before_added
+                 */
+                /**
+                 * @hooks
+                 * @hookdef hook-meta_mention-before_added 'meta_mention', 'before_added' - hook to override meta mention before it will be processed
+                 * It's equivalent to @ref hook-meta_keyword-before_added
+                 * except mention value is used in $object_id
+                 * @hook @ref hook-meta_mention-before_added
+                 */
+                bx_alert('meta_' . $sAlertName, 'before_added', $iObjectId, bx_get_logged_profile_id(), [
+                    'meta' => &$sMeta, 
+                    'content_id' => $iId, 
+                    'object' => $this->_sObject
+                ]);
+
+                $sSource = $this->_sObject . '_' . $iId;
+                /**
+                 * @hooks
+                 * @hookdef hook-bx_dol_metatags-keyword_added '{object_name}', 'keyword_added' - hook after meta keyword was processed (added)
+                 * - $unit_name - metatags object name
+                 * - $action - equals `keyword_added`
+                 * - $object_id - object id
+                 * - $sender_id - currently logged in profile id
+                 * - $extra_params - array of additional params with the following array keys:
+                 *      - `meta` - [string] keyword
+                 *      - `content_id` - [int] content id
+                 *      - `source` - [string] unique source id
+                 * @hook @ref hook-bx_dol_metatags-keyword_added
+                 */
+                /**
+                 * @hooks
+                 * @hookdef hook-bx_dol_metatags-mention_added '{object_name}', 'mention_added' - hook after meta mention was processed
+                 * It's equivalent to @ref hook-bx_dol_metatags-keyword_added
+                 * @hook @ref hook-bx_dol_metatags-mention_added
+                 */
+                bx_alert($this->_sObject, $sAlertName . '_added', $iObjectId, bx_get_logged_profile_id(), [
+                    'meta' => $sMeta, 
+                    'content_id' => $iId, 
+                    'source' => $sSource
+                ]);
+
+                /**
+                 * @hooks
+                 * @hookdef hook-meta_keyword-added 'meta_keyword', 'added' - hook after meta keyword was processed (added)
+                 * - $unit_name - equals `meta_keyword`
+                 * - $action - equals `added`
+                 * - $object_id - object id
+                 * - $sender_id - currently logged in profile id
+                 * - $extra_params - array of additional params with the following array keys:
+                 *      - `meta` - [string] keyword
+                 *      - `content_id` - [int] content id
+                 *      - `source` - [string] unique source id
+                 *      - `object` - [string] metatags object name
+                 * @hook @ref hook-meta_keyword-added
+                 */
+                /**
+                 * @hooks
+                 * @hookdef hook-meta_mention-added 'meta_mention', 'added' - hook after meta mention was processed
+                 * It's equivalent to @ref hook-meta_keyword-added
+                 * @hook @ref hook-meta_mention-added
+                 */
+                bx_alert('meta_' . $sAlertName, 'added', $iObjectId, bx_get_logged_profile_id(), [
+                    'meta' => $sMeta, 
+                    'content_id' => $iId, 
+                    'source' => $sSource, 
+                    'object' => $this->_sObject
+                ]);
+            }
+        }
+
+        return $iRet;
+    }
+}
+
+/** @} */

+ 596 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/classes/BxDolScore.php

@@ -0,0 +1,596 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+bx_import('BxDolAcl');
+
+define('BX_DOL_SCORE_USAGE_BLOCK', 'block');
+define('BX_DOL_SCORE_USAGE_INLINE', 'inline');
+define('BX_DOL_SCORE_USAGE_DEFAULT', BX_DOL_SCORE_USAGE_BLOCK);
+
+define('BX_DOL_SCORE_DO_UP', 'up');
+define('BX_DOL_SCORE_DO_DOWN', 'down');
+
+/**
+ * Score for any content
+ *
+ * Related classes:
+ * - BxDolScoreQuery - vote database queries
+ * - BxBaseScore - vote base representation
+ * - BxTemplScore - custom template representation
+ *
+ * AJAX vote for any content. Stars and Plus based representations are supported.
+ *
+ * To add vote section to your feature you need to add a record to 'sys_objects_vote' table:
+ *
+ * - ID - autoincremented id for internal usage
+ * - Name - your unique module name, with vendor prefix, lowercase and spaces are underscored
+ * - TableMain - table name where summary votigs are stored
+ * - TableTrack - table name where each vote is stored
+ * - PostTimeout - number of seconds to not allow duplicate vote
+ * - MinValue - min vote value, 1 by default
+ * - MaxValue - max vote value, 5 by default
+ * - IsUndo - is Undo enabled for Plus based votes
+ * - IsOn - is this vote object enabled
+ * - TriggerTable - table to be updated upon each vote
+ * - TriggerFieldId - TriggerTable table field with unique record id, primary key
+ * - TriggerFieldRate - TriggerTable table field with average rate
+ * - TriggerFieldRateCount - TriggerTable table field with votes count
+ * - ClassName - your custom class name, if you overrride default class
+ * - ClassFile - your custom class path
+ *
+ * You can refer to BoonEx modules for sample record in this table.
+ *
+ *
+ *
+ * @section example Example of usage:
+ * To get Star based vote you need to have different values for MinValue and MaxValue (for example 1 and 5)
+ * and IsUndo should be equal to 0. To get Plus(Like) based vote you need to have equal values
+ * for MinValue and MaxValue (for example 1) and IsUndo should be equal to 1. After filling the other
+ * paramenters in the table you can show vote in any place, using the following code:
+ * @code
+ * $o = BxDolScore::getObjectInstance('system object name', $iYourEntryId);
+ * if (!$o->isEnabled()) return '';
+ *     echo $o->getElementBlock();
+ * @endcode
+ *
+ *
+ * @section acl Memberships/ACL:
+ * - vote
+ *
+ *
+ *
+ * @section alerts Alerts:
+ * Alerts type/unit - every module has own type/unit, it equals to ObjectName.
+ * The following alerts are rised:
+ *
+ * - rate - comment was posted
+ *      - $iObjectId - entry id
+ *      - $iSenderId - rater user id
+ *      - $aExtra['rate'] - rate
+ *
+ */
+
+class BxDolScore extends BxDolObject
+{
+    protected static $_sCounterStyleSimple = 'simple'; // total counter only.
+    protected static $_sCounterStyleDivided = 'divided'; // counters [icon + counter] divided by action (up/down).
+
+    protected $_aScore;
+
+    protected $_aElementDefaults;
+    protected $_aElementDefaultsApi;
+    protected $_aElementParamsApi; //--- Params from DefaultsApi array to be passed to Api
+
+    protected function __construct($sSystem, $iId, $iInit = true, $oTemplate = false)
+    {
+        parent::__construct($sSystem, $iId, $iInit, $oTemplate);
+        if(empty($this->_sSystem))
+            return;
+
+        $this->_oQuery = new BxDolScoreQuery($this);
+    }
+
+    /**
+     * get votes object instanse
+     * @param $sSys vote object name
+     * @param $iId associated content id, where vote is available
+     * @param $iInit perform initialization
+     * @return null on error, or ready to use class instance
+     */
+    public static function getObjectInstance($sSys, $iId, $iInit = true, $oTemplate = false)
+    {
+        $sKey = 'BxDolScore!' . $sSys . $iId . ($oTemplate ? $oTemplate->getClassName() : '');
+        if(isset($GLOBALS['bxDolClasses'][$sKey]))
+            return $GLOBALS['bxDolClasses'][$sKey];
+
+        $aSystems = self::getSystems();
+        if(!isset($aSystems[$sSys]))
+            return null;
+
+        $sClassName = 'BxTemplScore';
+        if(!empty($aSystems[$sSys]['class_name'])) {
+            $sClassName = $aSystems[$sSys]['class_name'];
+            if(!empty($aSystems[$sSys]['class_file']))
+                require_once(BX_DIRECTORY_PATH_ROOT . $aSystems[$sSys]['class_file']);
+        }
+
+        $o = new $sClassName($sSys, $iId, $iInit, $oTemplate);
+        return ($GLOBALS['bxDolClasses'][$sKey] = $o);
+    }
+
+    public static function &getSystems()
+    {
+        $sKey = 'bx_dol_cache_memory_score_systems';
+
+        if(!isset($GLOBALS[$sKey]))
+            $GLOBALS[$sKey] = BxDolDb::getInstance()->fromCache('sys_objects_score', 'getAllWithKey', '
+                SELECT
+                    `id` as `id`,
+                    `name` AS `name`,
+                    `module` AS `module`,
+                    `table_main` AS `table_main`,
+                    `table_track` AS `table_track`,
+                    `post_timeout` AS `post_timeout`,
+                    `pruning` AS `pruning`,
+                    `is_undo` AS `is_undo`,
+                    `is_on` AS `is_on`,
+                    `trigger_table` AS `trigger_table`,
+                    `trigger_field_id` AS `trigger_field_id`,
+                    `trigger_field_author` AS `trigger_field_author`,
+                    `trigger_field_score` AS `trigger_field_score`,
+                    `trigger_field_cup` AS `trigger_field_cup`,
+                    `trigger_field_cdown` AS `trigger_field_cdown`,
+                    `class_name` AS `class_name`,
+                    `class_file` AS `class_file`
+                FROM `sys_objects_score`', 'name');
+
+        return $GLOBALS[$sKey];
+    }
+
+    public static function onAuthorDelete ($iAuthorId)
+    {
+        $aSystems = self::getSystems();
+        foreach($aSystems as $sSystem => $aSystem)
+            self::getObjectInstance($sSystem, 0)->getQueryObject()->deleteAuthorEntries($iAuthorId);
+
+        return true;
+    }
+
+    public function isPerformed($iObjectId, $iAuthorId, $iAuthorIp = 0)
+    {
+        return parent::isPerformed($iObjectId, $iAuthorId) && !$this->_oQuery->isPostTimeoutEnded($iObjectId, $iAuthorId, $iAuthorIp);        
+    }
+
+	public function getObjectAuthorId($iObjectId = 0)
+    {
+    	if(empty($this->_aSystem['trigger_field_author']))
+    		return 0;
+
+        return $this->_oQuery->getObjectAuthorId($iObjectId ? $iObjectId : $this->getId());
+    }
+
+
+    /**
+     * Interface functions for outer usage
+     */
+    public function isUndo()
+    {
+        return (int)$this->_aSystem['is_undo'] == 1;
+    }
+
+    public function getStatCounterUp()
+    {
+        $aScore = $this->_getVote();
+        return $aScore['count_up'];
+    }
+
+    public function getStatCounterDown()
+    {
+        $aScore = $this->_getVote();
+        return $aScore['count_down'];
+    }
+
+    public function getStatScore()
+    {
+        $aScore = $this->_getVote();
+        return $aScore['score'];
+    }
+
+    public function getSocketName()
+    {
+        return $this->_sSystem . '_scores';
+    }
+
+    /**
+     * Actions functions
+     */
+    public function actionVoteUp()
+    {
+        if(!$this->isEnabled())
+            return echoJson(array('code' => 1, 'message' => _t('_sys_score_err_not_enabled')));
+        
+        $aVoteData = ['type' => BX_DOL_SCORE_DO_UP];
+        $aRequestParamsData = $this->_getRequestParamsData();
+        
+        return echoJson($this->vote($aVoteData, $aRequestParamsData));
+    }
+
+    public function actionVoteDown()
+    {
+        if(!$this->isEnabled())
+            return echoJson(array('code' => 1, 'message' => _t('_sys_score_err_not_enabled')));
+
+        $aVoteData = ['type' => BX_DOL_SCORE_DO_DOWN];
+        $aRequestParamsData = $this->_getRequestParamsData();
+
+        return echoJson($this->vote($aVoteData, $aRequestParamsData));
+    }
+
+    public function actionGetVotedBy()
+    {
+        if (!$this->isEnabled())
+           return '';
+
+        $aParams = $this->_getRequestParamsData();
+
+        if(($sType = bx_get('type')) !== false) {
+            $sType = bx_process_input($sType);
+
+            if(in_array($sType, [BX_DOL_SCORE_DO_UP, BX_DOL_SCORE_DO_DOWN]))
+                $aParams['type'] = $sType;
+        }
+
+        return $this->_getVotedBy($aParams);
+    }
+
+    public function vote($aVoteData = [], $aRequestParamsData = [])
+    {
+        if(!$this->isAllowedVote(true))
+            return ['code' => BX_DOL_OBJECT_ERR_ACCESS_DENIED, 'message' => $this->msgErrAllowedVote()];
+
+        $sType = $aVoteData['type'];
+        $iObjectId = $this->getId();
+        $iObjectAuthorId = $this->getObjectAuthorId($iObjectId);
+        $iAuthorId = $this->_getAuthorId();
+        $iAuthorIp = $this->_getAuthorIp();
+
+        $bUndo = $this->isUndo();
+        $bVoted = $this->isPerformed($iObjectId, $iAuthorId, $iAuthorIp);
+        $bPerformUndo = $bVoted && $bUndo;
+
+        if(!$bPerformUndo && !$this->isAllowedVote())
+            return ['code' => BX_DOL_OBJECT_ERR_ACCESS_DENIED, 'message' => $this->msgErrAllowedVote()];
+
+        if($bVoted && !$bUndo)
+            return ['code' => BX_DOL_OBJECT_ERR_DUPLICATE, 'message' => _t('_sys_score_err_duplicate_vote')];
+
+        if($bPerformUndo) {
+            $aTrack = $this->_getTrack($iObjectId, $iAuthorId);
+            if(!empty($aTrack) && is_array($aTrack))
+                $aVoteData = array_intersect_key($aTrack, $aVoteData);
+        }
+
+        $iId = $this->_putVoteData($iObjectId, $iAuthorId, $iAuthorIp, $aVoteData, $bPerformUndo);
+        if($iId === false)
+            return ['code' => BX_DOL_OBJECT_ERR_CANNOT_PERFORM];
+
+        if(!$bPerformUndo)
+            $this->isAllowedVote(true);
+
+        $this->_trigger();
+
+        $sTypeUc = ucfirst($sType);
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_score-doVoteUp '{object_name}', 'doVoteUp' - hook after score vote 
+         * - $unit_name - score object name
+         * - $action - equals `doVoteUp`
+         * - $object_id - object id which got a vote
+         * - $sender_id - profile id who voted
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `score_id` - [int] vote id
+         *      - `score_author_id` - [int] profile id who voted
+         *      - `object_author_id` - [int] author id of the object which got a vote
+         * @hook @ref hook-bx_dol_score-doVoteUp
+         */
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_score-doVoteDown '{object_name}', 'doVoteDown' - hook after score vote 
+         * It's equivalent to @ref hook-bx_dol_score-doVoteUp
+         * @hook @ref hook-bx_dol_score-doVoteDown
+         */
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_score-undoVoteUp '{object_name}', 'undoVoteUp' - hook after undo score vote 
+         * It's equivalent to @ref hook-bx_dol_score-doVoteUp
+         * @hook @ref hook-bx_dol_score-undoVoteUp
+         */
+        /**
+         * @hooks
+         * @hookdef hook-bx_dol_score-undoVoteDown '{object_name}', 'undoVoteDown' - hook after undo score vote 
+         * It's equivalent to @ref hook-bx_dol_score-doVoteUp
+         * @hook @ref hook-bx_dol_score-undoVoteDown
+         */
+        bx_alert($this->_sSystem, ($bPerformUndo ? 'un' : '') . 'doVote' . $sTypeUc, $iObjectId, $iAuthorId, [
+            'score_id' => $iId, 
+            'score_author_id' => $iAuthorId, 
+            'object_author_id' => $iObjectAuthorId
+        ]);
+
+        /**
+         * @hooks
+         * @hookdef hook-score-doUp 'score', 'doUp' - hook after score vote 
+         * - $unit_name - equals `score`
+         * - $action - equals `doUp`
+         * - $object_id - score vote id
+         * - $sender_id - profile id who voted
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `object_system` - [string] vote object name
+         *      - `object_id` - [int] object id which got a vote
+         *      - `object_author_id` - [int] author id of the object which got a vote
+         * @hook @ref hook-score-doUp
+         */
+        /**
+         * @hooks
+         * @hookdef hook-score-doDown 'score', 'doDown' - hook after score vote 
+         * It's equivalent to @ref hook-score-doUp
+         * @hook @ref hook-score-doDown
+         */
+        /**
+         * @hooks
+         * @hookdef hook-score-undoUp 'score', 'undoUp' - hook after undo score vote 
+         * It's equivalent to @ref hook-score-doUp
+         * @hook @ref hook-score-undoUp
+         */
+        /**
+         * @hooks
+         * @hookdef hook-score-undoDown 'score', 'undoDown' - hook after undo score vote 
+         * It's equivalent to @ref hook-score-doUp
+         * @hook @ref hook-score-undoDown
+         */
+        bx_alert('score', ($bPerformUndo ? 'un' : '') . 'do' . $sTypeUc, $iId, $iAuthorId, [
+            'object_system' => $this->_sSystem, 
+            'object_id' => $iObjectId, 
+            'object_author_id' => $iObjectAuthorId
+        ]);
+
+        $aRequestParamsData['show_script'] = false;
+
+        $bVoted = !$bVoted;
+        $aScore = $this->_getVote($iObjectId, true);
+        $iCup = (int)$aScore['count_up'];
+        $iCdown = (int)$aScore['count_down'];
+
+        $aResult = [
+            'code' => 0,
+            'type' => $sType,
+            'score' => $aScore['score'],
+            'scoref' => $iCup > 0 || $iCdown > 0 ? $this->_getCounterLabel($aScore['score'], $aRequestParamsData) : '',
+            'cup' => $iCup,
+            'cdown' => $iCdown,
+            'counter' => $this->getCounter($aRequestParamsData),
+            'label_icon' => $this->_getIconDo($sType),
+            'label_title' => _t($this->_getTitleDo($sType)),
+            'voted' => $bVoted,
+            'disabled' => $bVoted && !$bUndo,
+        ];
+
+        $aResult['api'] = [
+            'performer_id' => $iAuthorId,
+            'is_voted' => $aResult['voted'], //TODO: Remove after 14.0.0-RC3 is released.
+            'is_disabled' => $aResult['disabled'], //TODO: Remove after 14.0.0-RC3 is released.
+            $sType => [
+                'is_voted' => $aResult['voted'],
+                'is_disabled' => $aResult['disabled'],
+                'icon' => $aResult['label_icon'],
+                'title' => $aResult['label_title'],
+            ],
+            'counter' => $this->getVote()
+        ];
+
+        if(($oSockets = BxDolSockets::getInstance()) && $oSockets->isEnabled())
+            $oSockets->sendEvent($this->getSocketName(), $iObjectId, 'voted', json_encode($this->_returnVoteDataForSocket($aResult)));
+
+        return $aResult;
+    }
+
+
+    /**
+     * Permissions functions
+     */
+    public function isAllowedVote($isPerformAction = false)
+    {
+        if(isAdmin())
+            return true;
+
+        if(!$this->checkAction('vote', $isPerformAction))
+            return false;
+
+        $aObject = $this->_oQuery->getObjectInfo($this->_iId);
+        if(empty($aObject) || !is_array($aObject))
+            return false;
+
+        return $this->_isAllowedVoteByObject($aObject);
+    }
+
+    public function msgErrAllowedVote()
+    {
+        $sMsg = $this->checkActionErrorMsg('vote');
+        if(empty($sMsg))
+            $sMsg = _t('_sys_txt_access_denied');
+
+        return $sMsg;
+    }
+
+    public function isAllowedVoteView($isPerformAction = false)
+    {
+        if(isAdmin())
+            return true;
+
+        return $this->checkAction('vote_view', $isPerformAction);
+    }
+
+    public function msgErrAllowedVoteView()
+    {
+        return $this->checkActionErrorMsg('vote_view');
+    }
+
+    public function isAllowedVoteViewVoters($isPerformAction = false)
+    {
+        if(isAdmin())
+            return true;
+
+        return $this->checkAction('vote_view_voters', $isPerformAction);
+    }
+
+    public function msgErrAllowedVoteViewVoters()
+    {
+        return $this->checkActionErrorMsg('vote_view_voters');
+    }
+
+
+    /**
+     * Internal functions
+     */
+    protected function _isAllowedVoteByObject($aObject)
+    {
+        return bx_srv($this->_aSystem['module'], 'check_allowed_view_for_profile', [$aObject]) === CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    protected function _putVoteData($iObjectId, $iAuthorId, $iAuthorIp, $aData, $bPerformUndo)
+    {
+        return $this->_oQuery->putVote($iObjectId, $iAuthorId, $iAuthorIp, $aData, $bPerformUndo);
+    }
+
+    protected function _returnVoteDataForSocket($aData, $aMask = [])
+    {
+        if(empty($aMask) || !is_array($aMask))
+            $aMask = ['code', 'type', 'score', 'scoref', 'cup', 'cdown', 'counter', 'api'];
+
+        return array_intersect_key($aData, array_flip($aMask));
+    }
+
+    protected function _getVote($iObjectId = 0, $bForceGet = false)
+    {
+        if(!empty($this->_aScore) && !$bForceGet)
+            return $this->_aScore;
+
+        if(empty($iObjectId))
+            $iObjectId = $this->getId();
+
+        $this->_aScore = $this->_oQuery->getScore($iObjectId);
+        return $this->_aScore;
+    }
+
+    protected function _isVote($iObjectId = 0, $bForceGet = false)
+    {
+        $aScore = $this->_getVote($iObjectId, $bForceGet);
+
+        return $this->_isCount($aScore);
+    }
+
+    protected function _isCount($aScore = [])
+    {
+        if(empty($aScore))
+            $aScore = $this->_getVote();
+
+        return (isset($aScore['count_up']) && (int)$aScore['count_up'] != 0) || (isset($aScore['count_down']) && (int)$aScore['count_down'] != 0);
+    }
+
+    protected function _getTrack($iObjectId, $iAuthorId)
+    {
+        return $this->_oQuery->getTrack($iObjectId, $iAuthorId);
+    }
+
+    /**
+     * Note. By default image based controls aren't used.
+     * Therefore it can be overwritten in custom template.
+     */
+    protected function _getImageDo($sType)
+    {
+        $sResult = '';
+
+        switch($sType) {
+            case BX_DOL_SCORE_DO_UP:
+                $sResult = '';
+                break;
+            case BX_DOL_SCORE_DO_DOWN:
+                $sResult = '';
+                break;
+        }
+
+    	return $sResult;
+    }
+
+    protected function _getIconDo($sType = '')
+    {
+        $sResult = '';
+
+        switch($sType) {
+            case BX_DOL_SCORE_DO_UP:
+                $sResult = 'arrow-up';
+                break;
+
+            case BX_DOL_SCORE_DO_DOWN:
+                $sResult = 'arrow-down';
+                break;
+
+            default:
+                $sResult = 'arrows-alt-v';
+        }
+
+    	return $sResult;
+    }
+
+    protected function _getTitleDo($sType)
+    {
+        $sResult = '';
+
+        switch($sType) {
+            case BX_DOL_SCORE_DO_UP:
+                $sResult = '_sys_score_do_up';
+                break;
+            case BX_DOL_SCORE_DO_DOWN:
+                $sResult = '_sys_score_do_down';
+                break;
+        }
+
+    	return $sResult;
+    }
+
+    protected function _getTitleDoBy()
+    {
+    	return '_sys_score_do_by';
+    }
+
+    protected function _encodeElementParams($aParams)
+    {
+        if(empty($aParams) || !is_array($aParams))
+            return '';
+
+        return urlencode(base64_encode(json_encode($aParams)));
+    }
+
+    protected function _decodeElementParams($sParams, $bMergeWithDefaults = true)
+    {
+        $aParams = array();
+        if(!empty($sParams))
+            $aParams = json_decode(base64_decode(urldecode($sParams)), true);
+
+        if(empty($aParams) || !is_array($aParams))
+            $aParams = array();
+
+        if($bMergeWithDefaults)
+            $aParams = array_merge($this->_aElementDefaults, $aParams);
+
+        return $aParams;
+    }
+}
+
+/** @} */

+ 2767 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/utils.inc.php

@@ -0,0 +1,2767 @@
+<?php defined('BX_DOL') or defined('BX_DOL_INSTALL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCoreFunctions UNA Core Functions
+ * @{
+ */
+
+define('BX_DOL_LINK_CLASS', 'bx-link'); ///< class to add to every link in user content
+
+define('BX_DATA_TEXT', 1); ///< regular text data type
+define('BX_DATA_TEXT_MULTILINE', 2); ///< regular multiline text data type
+define('BX_DATA_INT', 3); ///< integer data type
+define('BX_DATA_FLOAT', 4); ///< float data type
+define('BX_DATA_CHECKBOX', 5); ///< checkbox data type, 'on' or empty value
+define('BX_DATA_HTML', 6); ///< HTML data type
+define('BX_DATA_DATE', 7); ///< date data type stored as yyyy-mm-dd
+define('BX_DATA_DATETIME', 12); ///< date/time data type stored as yyyy-mm-dd hh:mm:ss
+define('BX_DATA_DATE_TS', 8); ///< date data type stored as unixtimestamp
+define('BX_DATA_DATETIME_TS', 9); ///< date/time data type stored as unixtimestamp
+define('BX_DATA_DATE_TS_UTC', 10); ///< date data type stored as unixtimestamp from UTC time
+define('BX_DATA_DATETIME_TS_UTC', 11); ///< date/time data type stored as unixtimestamp from UTC time
+define('BX_DATA_DATE_UTC', 13); ///< date data type stored as yyyy-mm-dd in UTC time
+define('BX_DATA_DATETIME_UTC', 14); ///< date/time data type stored as yyyy-mm-dd in UTC time
+
+define('BX_SLASHES_AUTO', 0);
+define('BX_SLASHES_ADD', 1);
+define('BX_SLASHES_STRIP', 2);
+define('BX_SLASHES_NO_ACTION', 3);
+
+define('BX_ESCAPE_STR_AUTO', 0); ///< turn apostropes and quote signs into html special chars, for use in @see bx_js_string and @see bx_html_attribute
+define('BX_ESCAPE_STR_APOS', 1); ///< escape apostrophes only, for js strings enclosed in apostrophes, for use in @see bx_js_string and @see bx_html_attribute
+define('BX_ESCAPE_STR_QUOTE', 2); ///< escape quotes only, for js strings enclosed in quotes, for use in @see bx_js_string and @see bx_html_attribute
+
+define('BX_EMAIL_SYSTEM', 0); ///< system email without unsubscribe link, like forgot password or email verification
+define('BX_EMAIL_NOTIFY', 1); ///< notification message, with unsubscribe link
+define('BX_EMAIL_MASS', 2); ///< mass email, one mesage send to manu users, with unsubscribe link
+
+define('BX_MAINTENANCE_FILE', '.bx_maintenance'); ///< file name to use as mantenance mode indicator
+
+/**
+ * The following two functions are needed to convert title to uri and back.
+ * It usefull when titles are used in URLs, like in Categories and Tags.
+ */
+function title2uri($sValue)
+{
+    return str_replace(
+        array('&', '/', '\\', '"', '+'),
+        array('[and]', '[slash]', '[backslash]', '[quote]', '[plus]'),
+        $sValue
+    );
+}
+function uri2title($sValue)
+{
+    return str_replace(
+        array('[and]', '[slash]', '[backslash]', '[quote]', '[plus]'),
+        array('&', '/', '\\', '"', '+'),
+        $sValue
+    );
+}
+
+/*
+ * functions for limiting maximal text length
+ */
+function strmaxtextlen($sInput, $iMaxLen = 60, $sEllipsisSign = '&#8230;')
+{
+    $sTail = '';
+    $s = trim(strip_tags($sInput));
+    if (mb_strlen($s) > $iMaxLen) {
+        $s = mb_substr($s, 0, $iMaxLen);
+        $sTail = $sEllipsisSign;
+    }
+    return htmlspecialchars_adv($s) . $sTail;
+}
+
+function html2txt($content, $tags = "")
+{
+    while($content != strip_tags($content, $tags)) {
+        $content = strip_tags($content, $tags);
+    }
+
+    return $content;
+}
+
+/**
+ * Functions to process user input.
+ * DON'T use to process data before passing to SQL query - use db prepare instead @see BxDolDb::prepare.
+ * It is ok to use bx_process_input and then db prepare.
+ * @param $mixedData data to process
+ * @param $iDataType how to handle data, possible valies:
+ *      @code
+ *          BX_DATA_INT - integer value
+ *          BX_DATA_FLOAT - float values
+ *          BX_DATA_CHECKBOX - 'on' or empty string
+ *          BX_DATA_TEXT - text data, single line (default)
+ *          BX_DATA_TEXT_MULTILINE - text data, multiple lines
+ *          BX_DATA_HTML - HTML data
+ *          BX_DATA_DATE - date data type stored as yyyy-mm-dd
+ *          BX_DATA_DATETIME - date/time data type stored as yyyy-mm-dd hh:mm:ss
+ *          BX_DATA_DATE_TS' -  date data type stored as unixtimestamp
+ *          BX_DATA_DATETIME_TS - date/time data type stored as unixtimestamp
+ *          BX_DATA_DATE_TS_UTC - date data type stored as unixtimestamp from UTC time
+ *          BX_DATA_DATETIME_TS_UTC - date/time data type stored as unixtimestamp from UTC time
+ *      @endcode
+ * @param $mixedParams optional parameters to pass for validation
+ * @param $isCheckMagicQuotes deprecated
+ * @return the filtered data, or FALSE if the filter fails.
+ */
+function bx_process_input ($mixedData, $iDataType = BX_DATA_TEXT, $mixedParams = false, $isCheckMagicQuotes = true)
+{
+    if (is_array($mixedData)) {
+        foreach ($mixedData as $k => $v)
+            $mixedData[$k] = bx_process_input($v, $iDataType, $mixedParams);
+        return $mixedData;
+    }
+
+    switch ($iDataType) {
+    case BX_DATA_INT:
+        return filter_var(trim($mixedData), FILTER_VALIDATE_INT);
+    case BX_DATA_FLOAT:
+        return filter_var(trim($mixedData), FILTER_VALIDATE_FLOAT);
+    case BX_DATA_CHECKBOX:
+        return 'on' == trim($mixedData) ? 'on' : '';
+
+    case BX_DATA_DATE:
+        // maybe consider using strtotime
+        $mixedData = trim($mixedData);
+        if (!preg_match('/^\d{4}-\d{1,2}-\d{1,2}$/', $mixedData))
+            return false;
+        list($iYear, $iMonth, $iDay) = explode('-', $mixedData); // 1985-10-28
+        $iDay   = intval($iDay);
+        $iMonth = intval($iMonth);
+        $iYear  = intval($iYear);
+        return sprintf("%04d-%02d-%02d", $iYear, $iMonth, $iDay);
+    case BX_DATA_DATETIME:
+        // maybe consider using strtotime
+        $mixedData = trim($mixedData);
+        if (!preg_match('#(\d+)\-(\d+)\-(\d+)[\sT]{1}(\d+):(\d+):(\d+)#', $mixedData, $m) && !preg_match('#(\d+)\-(\d+)\-(\d+)[\sT]{1}(\d+):(\d+)#', $mixedData, $m))
+            return bx_process_input ($mixedData, BX_DATA_DATE, $mixedParams, $isCheckMagicQuotes);
+        $iDay   = intval($m[3]);
+        $iMonth = intval($m[2]);
+        $iYear  = intval($m[1]);
+        $iH = intval($m[4]);
+        $iM = intval($m[5]);
+        $iS = isset($m[6]) ? intval($m[6]) : 0;
+        return sprintf("%04d-%02d-%02d %02d:%02d:%02d", $iYear, $iMonth, $iDay, $iH, $iM, $iS); // 1985-10-28 00:59:35
+    case BX_DATA_DATE_TS:
+    case BX_DATA_DATE_TS_UTC:
+        $mixedData = trim($mixedData);
+        if (!preg_match('/^\d{4}-\d{1,2}-\d{1,2}$/', $mixedData))
+            return false;
+        list($iYear, $iMonth, $iDay) = explode('-', $mixedData);
+        $iDay   = intval($iDay);
+        $iMonth = intval($iMonth);
+        $iYear  = intval($iYear);
+        $sFunc = BX_DATA_DATE_TS_UTC == $iDataType ? 'gmmktime' : 'mktime';
+        $iRet = $sFunc (0, 0, 0, $iMonth, $iDay, $iYear);
+        return $iRet > 0 ? $iRet : false;
+    case BX_DATA_DATETIME_TS:
+    case BX_DATA_DATETIME_TS_UTC:
+        if (!preg_match('#(\d+)\-(\d+)\-(\d+)[\s]{1}(\d+):(\d+):(\d+)[\s]{1}([+\-\d:Z]+)#', $mixedData, $m) && !preg_match('#(\d+)\-(\d+)\-(\d+)[\sT]{1}(\d+):(\d+):(\d+)#', $mixedData, $m) && !preg_match('#(\d+)\-(\d+)\-(\d+)[\sT]{1}(\d+):(\d+)#', $mixedData, $m))
+            return bx_process_input ($mixedData, BX_DATA_DATETIME_TS == $iDataType ? BX_DATA_DATE_TS : BX_DATA_DATE_TS_UTC, $mixedParams, $isCheckMagicQuotes);
+        $iDay   = $m[3];
+        $iMonth = $m[2];
+        $iYear  = $m[1];
+        $iH = $m[4];
+        $iM = $m[5];
+        $iS = isset($m[6]) ? $m[6] : 0;        
+        $iTimezoneOffset = 0;
+        if (isset($m[7])) {
+            $oTz = new DateTimeZone($m[7]);
+            $oUtc = new DateTime(str_replace($m[7], '', $mixedData), new DateTimeZone('UTC'));
+            if ($oTz && $oUtc)
+                $iTimezoneOffset = $oTz->getOffset($oUtc);
+        }
+        $sFunc = BX_DATA_DATETIME_TS_UTC == $iDataType ? 'gmmktime' : 'mktime';
+        $iRet = $sFunc ($iH, $iM, $iS, $iMonth, $iDay, $iYear) - $iTimezoneOffset;
+
+        return $iRet > 0 ? $iRet : false;
+
+    case BX_DATA_HTML:
+        return clear_xss($mixedData);
+    case BX_DATA_TEXT_MULTILINE:
+        return nl2br(htmlspecialchars_adv($mixedData));
+    case BX_DATA_TEXT:
+    default:
+        return $mixedData;
+    }
+}
+
+/*
+ * Functions to process user output.
+ * Always use this function before output data which was entered by user before.
+ * @param $mixedData string to process
+ * @param $iDataType how to handle data, possible valies the same as in bx_process_input function, see bx_process_input.
+ * @param $mixedParams optional parameters to pass for validation
+ * @return the filtered data, or FALSE if the filter fails.
+ */
+function bx_process_output ($mixedData, $iDataType = BX_DATA_TEXT, $mixedParams = false)
+{
+    if (is_array($mixedData)) {
+        foreach ($mixedData as $k => $v)
+            $mixedData[$k] = bx_process_output($v, $iDataType, $mixedParams);
+        return $mixedData;
+    }
+
+    switch ($iDataType) {
+    case BX_DATA_INT:
+        return filter_var($mixedData, FILTER_VALIDATE_INT);
+    case BX_DATA_FLOAT:
+        return filter_var($mixedData, FILTER_VALIDATE_FLOAT);
+    case BX_DATA_CHECKBOX:
+        return 'on' == trim($mixedData) ? 'on' : '';
+
+    case BX_DATA_DATE:
+    case BX_DATA_DATETIME:
+        return $mixedData;
+    case BX_DATA_DATE_UTC:
+        if (!preg_match('/^(\d{4})-(\d{1,2})-(\d{1,2})$/', $mixedData, $m))
+            return '';
+        return sprintf("%04d-%02d-%02d 00:00:00Z", $m[1], $m[2], $m[3]);
+        return $mixedData . "Z";
+    case BX_DATA_DATETIME_UTC:
+        return $mixedData . "Z";
+    case BX_DATA_DATE_TS:
+        return empty($mixedData) ? '' : date("Y-m-d", (int)$mixedData);
+    case BX_DATA_DATE_TS_UTC:
+        return empty($mixedData) ? '' : gmdate("Y-m-d", (int)$mixedData);
+    case BX_DATA_DATETIME_TS:
+        return empty($mixedData) ? '' : date("Y-m-d H:i", (int)$mixedData);
+    case BX_DATA_DATETIME_TS_UTC:
+        return empty($mixedData) ? '' : gmdate("Y-m-d H:i:s\Z", (int)$mixedData);
+
+    case BX_DATA_HTML:
+        $s = bx_linkify_html($mixedData, 'class="' . BX_DOL_LINK_CLASS . '"');
+
+        // remove empty tags from html content: #4203, #4486, #4827
+        $s = preg_replace('/<([a-z]+)(?:\s[^>]*)?>((?:\s|<br\s*\/?>)*)<\/\1>/i', '', $s);
+        $s = preg_replace('/(<br\s*\/?>\s*){2,}/i', '<br>', $s);
+        return $mixedParams && is_array($mixedParams) && in_array('no_process_macro', $mixedParams) ? $s : bx_process_macros($s);
+    case BX_DATA_TEXT_MULTILINE:
+        $s = $mixedData;
+        return $mixedParams && is_array($mixedParams) && in_array('no_process_macros', $mixedParams) ? $s : bx_process_macros($s);
+    case BX_DATA_TEXT:
+    default:
+        $s = htmlspecialchars_adv($mixedData);
+        return $mixedParams && is_array($mixedParams) && in_array('no_process_macros', $mixedParams) ? $s : bx_process_macros($s);
+    }
+}
+
+function bx_is_macros_in_content (&$s) 
+{
+    return false === strpos($s, '{{~') ? false : true; 
+}
+
+/**
+ * This function converts macros upon text output. 
+ * Macros represents constructions like this:
+ * @code
+ * {{!module_name:function[param1, "param2"]}}
+ * @endcode
+ * For example, to display some content from module Posts:
+ * @code
+ * {{!bx_posts:get_search_result_unit[3]}}
+ * @endcode
+ * Only users which have "use macros" ACL action enabled can use this functionlity.
+ *
+ * @param $s text to process
+ * @return modified or not modified text
+ */
+function bx_process_macros ($s)
+{
+    if (!bx_is_macros_in_content($s))
+        return $s;
+
+    $aCode = [];
+    $c = 1;
+
+    $s = preg_replace_callback(
+        "/(<code>)(.*?)(<\/code>)/s",
+        function ($aMatches) use (&$c, &$aCode) {
+            $aCode[$c] = $aMatches[2];
+            return $aMatches[1] . '___' . $c++ . '___' . $aMatches[3];
+        },
+        $s);
+
+    $s = preg_replace_callback(
+        "/{{\~(.*?)\~}}/",
+        function ($aMatches) {
+            return BxDolService::callMacro($aMatches[1]);
+        },
+        $s);
+
+    $s = preg_replace_callback(
+        "/(<code>)___(.*?)___(<\/code>)/s",
+        function ($aMatches) use (&$aCode) {
+            $sCode = isset($aCode[$aMatches[2]]) ? $aCode[$aMatches[2]] : 'n/a';
+            return $aMatches[1] . $sCode . $aMatches[3];
+        },
+        $s);
+
+    return $s;
+}
+
+/*
+ * This function apply bx_process_input and then bx_process_output.
+ * Use this function to output data immediately after receiving, without saving to database.
+ * Patams are the same as bx_process_input function - @see bx_process_input
+ */
+function bx_process_pass ($mixedData, $iDataType = BX_DATA_TEXT, $mixedParams = false, $isCheckMagicQuotes = true)
+{
+    return bx_process_output(bx_process_input ($mixedData, $iDataType, $mixedParams, $isCheckMagicQuotes), $iDataType, $mixedParams);
+}
+
+/**
+ * DEPRECATED
+ * use bx_process_input + bx_process_output instead
+ * --------
+ * function for processing pass data
+ *
+ * This function cleans the GET/POST/COOKIE data if magic_quotes_gpc() is on
+ * for data which should be outputed immediately after submit
+ */
+/*
+function process_pass_data( $text, $strip_tags = 0 )
+{
+    if ( $strip_tags )
+        $text = strip_tags($text);
+    if ( !get_magic_quotes_gpc() )
+        return $text;
+    else
+        return stripslashes($text);
+}
+*/
+
+/*
+ * function for output data from database into html
+ */
+function htmlspecialchars_adv( $string )
+{
+    return htmlspecialchars($string, ENT_COMPAT, 'UTF-8', false);
+}
+
+/**
+ * Send mail to user by parsing email template
+ */
+function sendMailTemplate($sTemplateName, $iAccountId = 0, $iProfileId = 0, $aReplaceVars = array(), $iEmailType = BX_EMAIL_NOTIFY, $bAddToQueue = false)
+{
+    $oProfile = $iProfileId ? BxDolProfile::getInstance($iProfileId) : null;
+
+    $oAccount = $iAccountId ? BxDolAccount::getInstance($iAccountId) : ($oProfile ? $oProfile->getAccountObject() : null);
+
+    $oEmailTemplates = BxDolEmailTemplates::getInstance();
+
+    if (!($oAccount || $oProfile) || !$oEmailTemplates)
+        return false;
+
+    $aTemplate = $oEmailTemplates->parseTemplate($sTemplateName, $aReplaceVars, $oAccount ? $oAccount->id() : 0, (int)$iProfileId);
+    if (!$aTemplate)
+        return false;
+
+    return sendMail($oAccount->getEmail(), $aTemplate['Subject'], $aTemplate['Body'], 0, array(), $iEmailType, 'html', false, array(), $bAddToQueue);
+}
+
+/**
+ * Send system email 
+ */
+function sendMailTemplateSystem($sTemplateName, $aReplaceVars = array(), $iEmailType = BX_EMAIL_SYSTEM, $bAddToQueue = false)
+{
+    $oEmailTemplates = BxDolEmailTemplates::getInstance();
+
+    if (!$oEmailTemplates)
+        return false;
+
+    $aTemplate = $oEmailTemplates->parseTemplate($sTemplateName, $aReplaceVars);
+    if (!$aTemplate)
+        return false;
+
+    return sendMail(getParam('site_email'), $aTemplate['Subject'], $aTemplate['Body'], 0, array(), $iEmailType, 'html', false, array(), $bAddToQueue);
+}
+/**
+ * Send email function
+ *
+ * @param $mRecipientEmails - Email where email should be send, can be array, string with one email or comma separated
+ * @param $sMailSubject - subject of the message
+ * @param $sMailBody - Body of the message
+ * @param $iRecipientID - ID of recipient profile
+ * @param $aPlus - Array of additional information
+ * @param $iEmailType - email message type: BX_EMAIL_SYSTEM, BX_EMAIL_NOTIFY or BX_EMAIL_MASS
+ * @param $sEmailFlag - use 'html' for HTML email message
+ * @param $isDisableAlert - disable alert
+ * @param $aCustomHeaders - custom email headers
+ * @param $bAddToQueue - add message to email queue
+ * @return true if message was send or false otherwise
+ */
+function sendMail($mRecipientEmails, $sMailSubject, $sMailBody, $iRecipientID = 0, $aPlus = array(), $iEmailType = BX_EMAIL_NOTIFY, $sEmailFlag = 'html', $isDisableAlert = false, $aCustomHeaders = array(), $bAddToQueue = false)
+{
+    if (is_string($mRecipientEmails)){
+        if(strpos($mRecipientEmails, ',') !== false){
+            $mRecipientEmails = explode(',', $mRecipientEmails);
+        }
+        else{
+            return _sendMail($mRecipientEmails, $sMailSubject, $sMailBody, $iRecipientID, $aPlus, $iEmailType, $sEmailFlag, $isDisableAlert, $aCustomHeaders, $bAddToQueue);
+        }
+    }
+    
+    if (is_array($mRecipientEmails)) {
+        $bReturn = false;
+        foreach($mRecipientEmails as $sRecipientEmail) {
+            $sRecipientEmail = trim($sRecipientEmail);
+            if (_sendMail($sRecipientEmail, $sMailSubject, $sMailBody, $iRecipientID, $aPlus, $iEmailType, $sEmailFlag, $isDisableAlert, $aCustomHeaders, $bAddToQueue))
+                $bReturn = true;
+        }
+        return $bReturn;
+    }
+}
+
+function _sendMail($sRecipientEmail, $sMailSubject, $sMailBody, $iRecipientID = 0, $aPlus = array(), $iEmailType = BX_EMAIL_NOTIFY, $sEmailFlag = 'html', $isDisableAlert = false, $aCustomHeaders = array(), $bAddToQueue = false)
+{
+    // make sure that recipient's email is valid and message isn't empty
+    if (!$sMailBody || !$sRecipientEmail || preg_match('/\(2\)$/', $sRecipientEmail))
+        return false;
+
+    // get recipient account
+    $oAccount = !$isDisableAlert ? BxDolAccount::getInstance($sRecipientEmail) : null;
+    $aAccountInfo = $oAccount ? $oAccount->getInfo() : false;
+
+    // don't send bulk emails if user didn't subscribed to site news or email is unconfirmed
+    if ($aAccountInfo && BX_EMAIL_MASS == $iEmailType && (!$aAccountInfo['email_confirmed'] || !$aAccountInfo['receive_news']))
+        return false;
+
+    // don't send email notifications if user didn't subscribed to notifications or email is unconfirmed
+    if ($aAccountInfo && BX_EMAIL_NOTIFY == $iEmailType && (!$aAccountInfo['email_confirmed'] || !$aAccountInfo['receive_updates']))
+        return false;
+
+    // decode HTML entities in the subj
+    $sMailSubject = html_entity_decode($sMailSubject, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8');
+
+    if($bAddToQueue && BxDolQueueEmail::getInstance()->add($sRecipientEmail, $sMailSubject, $sMailBody, $iRecipientID, $aPlus, $iEmailType, $sEmailFlag, $isDisableAlert, $aCustomHeaders))
+        return true;
+
+    // if profile id is provided - get profile's info
+    $aRecipientInfo = false;
+    if ($iRecipientID) {
+        $oProfile = BxDolProfile::getInstance($iRecipientID);
+        if ($oProfile)
+            $aRecipientInfo = $oProfile->getInfo();
+    }
+
+    // get site vars
+    $sEmailNotify = !$isDisableAlert ? getParam('site_email_notify') : $sRecipientEmail;
+    $sSiteTitle = !$isDisableAlert ? getParam('site_title') : 'UNA ' . BX_DOL_VERSION;
+
+    // add unsubscribe link
+    if (!$isDisableAlert && empty($aPlus['unsubscribe'])) {
+        $aPlus['unsubscribe'] = '';
+        if ($oAccount && (BX_EMAIL_MASS == $iEmailType || BX_EMAIL_NOTIFY == $iEmailType))
+            $aPlus['unsubscribe'] = ($sLink = $oAccount->getUnsubscribeLink($iEmailType)) ? '<a href="' . BX_DOL_URL_ROOT . $sLink . '">' . _t('_sys_et_txt_unsubscribe') . '</a>' : '';
+    }
+
+    // parse template
+    if ($aPlus || $iRecipientID || $oAccount) {
+        if(!is_array($aPlus))
+            $aPlus = [];
+
+        $oEmailTemplates = BxDolEmailTemplates::getInstance();
+        $sMailSubject = $oEmailTemplates->parseContent($sMailSubject, $aPlus, !$iRecipientID && $oAccount ? $oAccount->id() : 0, $iRecipientID);
+        $sMailBody = $oEmailTemplates->parseContent($sMailBody, $aPlus, !$iRecipientID && $oAccount ? $oAccount->id() : 0, $iRecipientID);
+    }
+
+    if(($sRootUrl = getParam('sys_api_url_root_email')) !== '') {
+        if(substr(BX_DOL_URL_ROOT, -1) == '/' && substr($sRootUrl, -1) != '/')
+            $sRootUrl .= '/';
+
+        $sMailBody = str_replace(BX_DOL_URL_ROOT, $sRootUrl, $sMailBody);
+
+        // restore URLs in images
+        $sMailBody = str_replace([
+            'src="' . $sRootUrl, 
+            "src='" . $sRootUrl
+        ], [
+            'src="' . BX_DOL_URL_ROOT, 
+            "src='" . BX_DOL_URL_ROOT
+        ], $sMailBody);
+    }
+
+    // email message headers
+    $sMailHeader = '';
+    foreach ($aCustomHeaders as $sHeaderName => $sHeaderValue)
+        $sMailHeader = "$sHeaderName: $sHeaderValue\r\n" . $sMailHeader;
+
+    if (!isset($aCustomHeaders['From']))
+        $sMailHeader = "From: =?UTF-8?B?" . base64_encode( $sSiteTitle ) . "?= <{$sEmailNotify}>";
+
+    if (!isset($aCustomHeaders['MIME-Version']))
+        $sMailHeader = "MIME-Version: 1.0\r\n" . $sMailHeader;
+
+    $sMailParameters = isset($aCustomHeaders['Sender']) ? "-f{$aCustomHeaders['Sender']}" : "-f{$sEmailNotify}";
+
+    if (isset($aCustomHeaders['Subject']))
+        $sMailSubject = $aCustomHeaders['Subject'];
+
+    // build data for alert handler
+    $bResult = null;
+    $aAlert = array(
+        'email' => $sRecipientEmail,
+        'subject' => $sMailSubject,
+        'body' => $sMailBody,
+        'header' => $sMailHeader,
+        'params' => $sMailParameters,
+        'recipient' => $aRecipientInfo,
+        'email_type' => $iEmailType,
+        'html' => 'html' == $sEmailFlag ? true : false,
+        'custom_headers' => $aCustomHeaders,
+        'override_result' => &$bResult,
+    );
+    
+    /**
+     * @hooks
+     * @hookdef hook-system-check_send_mail 'system', 'check_send_mail' - hook for disabling mail sending 
+     * - $unit_name - equals `system`
+     * - $action - equals `check_send_mail` 
+     * - $object_id - recipient profile id 
+     * - $sender_id - not used 
+     * - $extra_params - array of additional params with the following array keys:
+     *      - `email` - [string] recipient email 
+     *      - `subject` - [string] email subject 
+     *      - `body` - [string] email body 
+     *      - `header` - [string] email headers, headers are separated with double new line character
+     *      - `params` - [string] parameters for php mail function 
+     *      - `recipient` - [array] recipient info array
+     *      - `email_type` - [int] email type, one of the following BX_EMAIL_SYSTEM, BX_EMAIL_NOTIFY, BX_EMAIL_MASS
+     *      - `html` - [boolean] `true` if email content is HTML, if `false` then email content is text
+     *      - `custom_headers` - [array] custom email headers array as key&value pairs
+     *      - `override_result` - [boolean] override result of `sendMail` function, if `true` then mail sending should continue as usual, if `false` then mail sending should stop
+     * @hook @ref hook-system-check_send_mail
+     */
+    bx_alert('system', 'check_send_mail', (isset($aRecipientInfo['ID']) ? $aRecipientInfo['ID'] : 0), '', $aAlert);
+    
+    if ($bResult !== null)
+        return $bResult;
+
+    // system alert
+    if (!$isDisableAlert) {
+        /**
+         * @hooks
+         * @hookdef hook-system-before_send_mail 'system', 'before_send_mail' - hook before mail sending
+         * It's equivalent to @ref hook-system-check_send_mail 
+         * @hook @ref hook-system-before_send_mail
+         */
+        bx_alert('system', 'before_send_mail', (isset($aRecipientInfo['ID']) ? $aRecipientInfo['ID'] : 0), '', $aAlert);
+        if ($bResult !== null)
+            return $bResult;
+        unset($aAlert['override_result']);
+    }
+
+    // prepare HTML/Plain message
+    if($sEmailFlag == 'html')
+        $sMailHeader = "Content-type: text/html; charset=UTF-8\r\n" . $sMailHeader;
+    else {
+        $sMailHeader = "Content-type: text/plain; charset=UTF-8\r\n" . $sMailHeader;
+        $sMailBody = html2txt($sMailBody);
+    }
+
+    // encode subject
+    if (0 !== strncasecmp($sMailSubject, '=?UTF-8?B?', 10))
+        $sMailSubject = '=?UTF-8?B?' . base64_encode($sMailSubject) . '?=';
+
+    // send mail or put it into queue
+    $bResult = mail($sRecipientEmail, $sMailSubject, $sMailBody, $sMailHeader, $sMailParameters);
+
+    // system alert
+    if (!$isDisableAlert) {
+        /**
+         * @hooks
+         * @hookdef hook-system-send_mail 'system', 'send_mail' - hook when mail was sent
+         * It's equivalent to @ref hook-system-check_send_mail 
+         * except `override_result` parameter in $extra_params is missing
+         * @hook @ref hook-system-send_mail
+         */
+        bx_alert('system', 'send_mail', (isset($aRecipientInfo['ID']) ? $aRecipientInfo['ID'] : 0), '', $aAlert);
+    }
+
+    return $bResult;
+}
+
+/*
+ * Getting an array with Templates' Names
+ */
+function get_templates_array($bEnabledOnly = true, $bShortInfo = true)
+{
+    $oDb = BxDolDb::getInstance();
+
+    $sWhereAddon = $bEnabledOnly ? " AND `enabled`='1'" : "";
+
+    if($bShortInfo)
+        return $oDb->getPairs("SELECT `uri`, `title` FROM `sys_modules` WHERE 1 AND `type`='" . BX_DOL_MODULE_TYPE_TEMPLATE . "'" . $sWhereAddon, "uri", "title");
+    else
+        return $oDb->getAllWithKey("SELECT * FROM `sys_modules` WHERE 1 AND `type`='" . BX_DOL_MODULE_TYPE_TEMPLATE . "'" . $sWhereAddon, "uri");
+}
+
+function bx_get_image_exif_and_size($oStorage, $oTranscoder, $iContentId){
+    $sData = '';
+    $sExif = '';
+    $aExif = false;
+    $aPhoto = $oStorage->getFile($iContentId);
+    if ($oTranscoder->isMimeTypeSupported($aPhoto['mime_type'])) {
+        $oImageReize = BxDolImageResize::getInstance();
+        
+        $a = $oImageReize->getImageSize($oTranscoder->getFileUrl($iContentId));
+        $sData = isset($a['w']) && isset($a['h']) ? $a['w'] . 'x' . $a['h'] : '';
+        
+        if ($aExif = $oImageReize->getExifInfo($oStorage->getFileUrlById($iContentId))) {
+            $a = array('Make', 'Model', 'FocalLength', 'ShutterSpeedValue', 'ExposureTime', 'ISOSpeedRatings', 'Orientation', 'Artist', 'Copyright', 'Flash', 'WhiteBalance', 'DateTimeOriginal', 'DateTimeDigitized', 'ExifVersion', 'COMPUTED', 'GPSLatitudeRef', 'GPSLatitude', 'GPSLongitudeRef', 'GPSLongitude', 'GPSAltitudeRef', 'GPSAltitude', 'GPSTimeStamp', 'GPSImgDirectionRef', 'GPSImgDirection', 'GPSDateStamp');
+            $aExifFiltered = array();
+            foreach ($a as $sIndex)
+                if (isset($aExif[$sIndex]))
+                    $aExifFiltered[$sIndex] = $aExif[$sIndex];
+            $sExif = serialize($aExifFiltered);
+        }
+    }
+    return array('exif' => $sExif, 'size' => $sData);
+}
+
+function bx_get_svg_image_size($sUrl)
+{
+    $iWidth = $iHeight = 0;
+
+    $sContent = bx_file_get_contents($sUrl);
+    if(empty($sContent))
+        return [$iWidth, $iHeight];
+
+    $aAttributes = BxDolXmlParser::getInstance()->getAttributes($sContent, 'SVG', 0);
+    if(isset($aAttributes['VIEWBOX'])) {
+        $aViewBox = explode(' ', $aAttributes['VIEWBOX']);
+        if(!empty($aViewBox) && is_array($aViewBox) && count($aViewBox) == 4) {
+            $iWidth = (float)$aViewBox[2];
+            $iHeight = (float)$aViewBox[3];
+        }
+    }
+
+    if(!$iWidth && isset($aAttributes['WIDTH']))
+        $iWidth = $this->_str2px($aAttributes['WIDTH']);
+
+    if(!$iHeight && isset($aAttributes['HEIGHT']))
+        $iHeight = $this->_str2px($aAttributes['HEIGHT']);
+
+    return [$iWidth, $iHeight];
+}
+
+function extFileExists( $sFileSrc )
+{
+    return (file_exists( $sFileSrc ) && is_file( $sFileSrc )) ? true : false;
+}
+
+function getVisitorIP()
+{
+    $ip = "0.0.0.0";
+    if (isset($_SERVER['HTTP_X_FORWARDED_FOR']) && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
+        $ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
+    } elseif (isset( $_SERVER['HTTP_CLIENT_IP']) && !empty($_SERVER['HTTP_CLIENT_IP'])) {
+        $ip = explode(".",$_SERVER['HTTP_CLIENT_IP']);
+        $ip = $ip[3].".".$ip[2].".".$ip[1].".".$ip[0];
+    } elseif (!isset( $_SERVER['HTTP_X_FORWARDED_FOR']) || empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
+        if (!isset( $_SERVER['HTTP_CLIENT_IP']) && empty($_SERVER['HTTP_CLIENT_IP']) && isset($_SERVER['REMOTE_ADDR']))
+            $ip = $_SERVER['REMOTE_ADDR'];
+    }
+    return $ip;
+}
+
+function genFlag($sLang = '', $oTemplate = null)
+{
+    if (!$oTemplate)
+        $oTemplate = BxDolTemplate::getInstance();
+    $oTemplate->addCss(BX_DIRECTORY_PATH_PLUGINS_PUBLIC . 'flag-icons/css/|flag-icons.min.css');
+    $sFlag = BxDolLanguages::getInstance()->getLangFlag($sLang);
+    return '<span title="' . $sFlag . '" class="fi fi-' . $sFlag . '"></span>';
+}
+
+// print debug information ( e.g. arrays )
+function echoDbg( $what, $desc = '' )
+{
+    if ( $desc )
+        echo "<b>$desc:</b> ";
+    echo "<pre>";
+        print_r( $what );
+    echo "</pre>\n";
+}
+
+function echoDbgLog($mWhat, $sDesc = 'unused', $sFileName = 'unused')
+{
+    bx_log('sys_debug', $mWhat);
+}
+
+function bx_is_dbg()
+{
+    return (defined('BX_DBG_COOKIE') && !empty($_COOKIE[BX_DBG_COOKIE])) || (defined('BX_DBG_PROFILES') && in_array(bx_get_logged_profile_id(), BX_DBG_PROFILES));
+}
+
+function dbgTiming($sStartMicrotime)
+{
+    $i1 = explode(' ', microtime ());
+    $i2 = explode(' ', $sStartMicrotime);
+    $iTime = ($i1[0]+$i1[1]) - ($i2[0]+$i2[1]);
+    return round($iTime, 6) . ' sec';
+}
+
+function echoJson($a)
+{
+	header('Content-Type: application/json; charset=utf-8');
+
+	echo json_encode($a);
+}
+
+function clear_xss($val)
+{
+    // HTML Purifier plugin
+    global $logged;
+    global $oHtmlPurifier;
+
+    $bAdmin = isset($logged) && $logged['admin'];
+
+    if (!isset($oHtmlPurifier) && !$bAdmin) {
+        HTMLPurifier_Bootstrap::registerAutoload();
+
+        $oConfig = HTMLPurifier_Config::createDefault();
+
+        $oConfig->set('Cache.SerializerPath', rtrim(BX_DIRECTORY_PATH_CACHE, '/'));
+        $oConfig->set('Cache.SerializerPermissions', BX_DOL_DIR_RIGHTS);
+
+        $oConfig->set('HTML.SafeObject', 'true');
+        $oConfig->set('Output.FlashCompat', 'true');
+        $oConfig->set('HTML.FlashAllowFullScreen', 'true');
+        $oConfig->set('Attr.AllowedFrameTargets', array('_blank', '_self', '_parent', '_top'));
+
+        if (getParam('sys_add_nofollow')) {
+            $sHost = parse_url(BX_DOL_URL_ROOT, PHP_URL_HOST);
+            $oConfig->set('URI.Host', $sHost);
+            $oConfig->set('HTML.Nofollow', 'true');
+        }
+
+        $oConfig->set('Filter.Custom', array (
+            new BxDolHTMLPurifierFilterYouTube(), 
+            new BxDolHTMLPurifierFilterYoutubeIframe(), 
+            new BxDolHTMLPurifierFilterAddBxLinksClass(), 
+            new BxDolHTMLPurifierFilterLocalIframe(),
+            new BxDolHTMLPurifierFilterEmbed(),
+        ));
+   
+	    $oConfig->set('HTML.DefinitionID', 'html5-definitions');
+		$oConfig->set('HTML.DefinitionRev', 1);
+      
+		if ($def = $oConfig->maybeGetRawHTMLDefinition()) {
+		    $def->addElement('section', 'Block', 'Flow', 'Common');
+		    $def->addElement('nav',     'Block', 'Flow', 'Common');
+		    $def->addElement('article', 'Block', 'Flow', 'Common');
+		    $def->addElement('aside',   'Block', 'Flow', 'Common');
+		    $def->addElement('header',  'Block', 'Flow', 'Common');
+		    $def->addElement('footer',  'Block', 'Flow', 'Common');
+		    $def->addElement('video', 'Block', 'Optional: (source, Flow) | (Flow, source) | Flow', 'Common', array(
+		        'src' => 'URI',
+		        'type' => 'Text',
+		        'width' => 'Length',
+		        'height' => 'Length',
+		        'poster' => 'URI',
+		        'preload' => 'Enum#auto,metadata,none',
+		        'controls' => 'Bool',
+		    ));
+		    $def->addElement('source', 'Block', 'Flow', 'Common', array(
+		        'src' => 'URI',
+		        'type' => 'Text',
+            ));
+            $def->addAttribute('a', 'data-profile-id', 'Text');
+            $def->addAttribute('a', 'dchar', 'Text');
+            $def->addAttribute('div', 'source', 'Text');
+		}
+
+        $oHtmlPurifier = new HTMLPurifier($oConfig);
+    }
+
+    /**
+     * Note. I'm not sure if double 'purify' is needed for non-admins.
+     */
+    if(!$bAdmin)
+        $val = $oHtmlPurifier->purify($val);
+
+    $sNewVal = $val;
+    if(!$bAdmin)
+        $sNewVal = $oHtmlPurifier->purify($val);
+
+    /**
+     * @hooks
+     * @hookdef hook-account-clear_xss 'account', 'clear_xss' - hook after string is checked for xss
+     * - $unit_name - equals `system`
+     * - $action - equals `clear_xss` 
+     * - $object_id - not used 
+     * - $sender_id - not used 
+     * - $extra_params - array of additional params with the following array keys:
+     *      - `oHtmlPurifier` - [object] HtmlPurifier object 
+     *      - `input_data` - [string] string before check xss
+     *      - `return_data` - [string] by ref, string before check xss, can be overridden in hook processing
+     * @hook @ref hook-system-clear_xss
+     */
+    bx_alert('system', 'clear_xss', 0, 0, array('oHtmlPurifier' => $oHtmlPurifier, 'input_data' => $val, 'return_data' => &$sNewVal));
+
+    return $val;
+}
+
+//--------------------------------------- friendly permalinks --------------------------------------//
+//------------------------------------------- main functions ---------------------------------------//
+function uriGenerate ($sValue, $sTable, $sField, $aParams = [])
+{
+    $sDivider = isset($aParams['divider']) ? $aParams['divider'] : '-';
+    $aCond = isset($aParams['cond']) && is_array($aParams['cond']) ? $aParams['cond'] : [];
+
+    $sValue = uriFilter($sValue, $aParams);
+    if(uriCheckUniq($sValue, $sTable, $sField, $aCond))
+        return $sValue;
+
+    // cut off redundant part
+    if(get_mb_len($sValue) > 240)
+        $sValue = get_mb_substr($sValue, 0, 240);
+
+    // try to add number
+    for($i = 0 ; $i < 999 ; ++$i) {
+        $iRnd = mt_rand(1000, 9999);
+        if(uriCheckUniq($sValue . $sDivider . $iRnd, $sTable, $sField, $aCond))
+            return ($sValue . $sDivider . $iRnd);
+    }
+
+    return rand(0, PHP_INT_MAX);
+}
+
+function uriFilter ($s, $aParams = [])
+{
+    $sEmpty = isset($aParams['empty']) ? $aParams['empty'] : '-';
+    $sDivider = isset($aParams['divider']) ? $aParams['divider'] : '-';
+
+    if(BxTemplConfig::getInstance()->bAllowUnicodeInPreg)
+        $s = get_mb_replace ('/[^\pL^\pN^_]+/u', $sDivider, $s); // unicode characters
+    else
+        $s = get_mb_replace ('/([^\d^\w]+)/u', $sDivider, $s); // latin characters only
+
+    $s = get_mb_replace ('/([' . $sDivider . '^]+)/', $sDivider, $s);
+    $s = get_mb_replace ('/([' . $sDivider . ']+)$/', '', $s); // remove trailing dash
+    if(!$s) 
+        $s = $sEmpty;
+
+    return !isset($aParams['lowercase']) || $aParams['lowercase'] === true ? mb_strtolower($s) : $s;
+}
+
+function uriCheckUniq ($sValue, $sTable, $sField, $aCond = [])
+{
+    $oDb = BxDolDb::getInstance();
+
+    $sWhere = $aCond ? $oDb->arrayToSQL($aCond, ' AND ') : '1';
+    $sSql = $oDb->prepare("SELECT 1 FROM `$sTable` WHERE $sWhere AND `$sField`=? LIMIT 1", $sValue);
+    return !$oDb->query($sSql);
+}
+
+function get_mb_replace ($sPattern, $sReplace, $s)
+{
+    return preg_replace ($sPattern, $sReplace, $s);
+}
+
+function get_mb_len ($s)
+{
+    return (function_exists('mb_strlen')) ? mb_strlen($s) : strlen($s);
+}
+
+function get_mb_substr ($s, $iStart, $iLen)
+{
+    return (function_exists('mb_substr')) ? mb_substr ($s, $iStart, $iLen) : substr ($s, $iStart, $iLen);
+}
+
+function bx_mb_substr_replace($s, $sReplace, $iPosStart, $iLength)
+{
+    return mb_substr($s, 0, $iPosStart) . $sReplace . mb_substr($s, $iPosStart + $iLength);
+}
+
+function bx_mb_strpos ($s, $sReplacement, $iStart = 0)
+{
+    return mb_strpos($s, $sReplacement, $iStart);
+}
+
+/**
+ * Import class file, it automatically detects class path by its prefix or module array/name
+ *
+ * @param $sClassName - full class name or class postfix(withoit prefix) in the case of module class
+ * @param $mixedModule - module array or module name in the case of module class
+ */
+function bx_import($sClassName, $mixedModule = array())
+{
+    if (class_exists($sClassName, false))
+        return;
+
+    $aModule = false;
+    if ($mixedModule) {
+        if (is_array($mixedModule)) {
+            $aModule = $mixedModule;
+        } elseif (is_string($mixedModule)) {
+            $o = BxDolModule::getInstance($mixedModule);
+            $aModule = $o->_aModule;
+        } elseif (is_bool($mixedModule) && true === $mixedModule) {
+            $aModule = $GLOBALS['aModule'];
+        }
+    }
+
+    if ($aModule) {
+        if (class_exists($aModule['class_prefix'] . $sClassName, false))
+            return;
+        require_once (BX_DIRECTORY_PATH_MODULES . $aModule['path'] . 'classes/' . $aModule['class_prefix'] . $sClassName . '.php');
+        return;
+    }
+
+    if (0 === strncmp($sClassName, 'BxDol', 5)) {
+        if (0 === strncmp($sClassName, 'BxDolStudio', 11))
+            require_once(BX_DOL_DIR_STUDIO_CLASSES . $sClassName . '.php');
+        else
+            require_once(BX_DIRECTORY_PATH_CLASSES . $sClassName . '.php');
+        return;
+    }
+
+    if (0 === strncmp($sClassName, 'BxBase', 6)) {
+        if (0 === strncmp($sClassName, 'BxBaseMod', 9)) {
+            $aMatches = array();
+            if (preg_match('/BxBaseMod([A-Z][a-z]+)/', $sClassName, $aMatches)) {
+                require_once(BX_DIRECTORY_PATH_MODULES . 'base/' . strtolower($aMatches[1]) . '/classes/' . $sClassName . '.php');
+                return;
+            }
+        } if (0 === strncmp($sClassName, 'BxBaseStudio', 12)) {
+            require_once(BX_DOL_DIR_STUDIO_BASE . 'scripts/' . $sClassName . '.php');
+            return;
+        } else {
+            require_once(BX_DIRECTORY_PATH_BASE . 'scripts/' . $sClassName . '.php');
+            return;
+        }
+    }
+
+    if (0 === strncmp($sClassName, 'BxTempl', 7)) {
+        if(0 === strncmp($sClassName, 'BxTemplStudio', 13)) {
+            $sPath = BX_DIRECTORY_PATH_MODULES . BxDolStudioTemplate::getInstance()->getPath() . 'data/template/studio/scripts/' . $sClassName . '.php';
+            if (!file_exists($sPath))
+                $sPath = BX_DOL_DIR_STUDIO_BASE . 'scripts_templ/' . $sClassName . '.php';
+        } else {
+            $sPath = BX_DIRECTORY_PATH_MODULES . BxDolTemplate::getInstance()->getPath() . 'data/template/system/scripts/' . $sClassName . '.php';
+        }
+
+        if (file_exists($sPath)) {
+            require_once($sPath);
+            return;
+        }
+        else{
+            require_once(BX_DIRECTORY_PATH_BASE . 'scripts_templ/' . $sClassName . '.php');
+            return;
+        }
+
+        trigger_error ("bx_import fatal error: class (" . $sClassName . ") wasn't found", E_USER_ERROR);
+    }
+}
+
+/**
+ * used in spl_autoload_register() function, so no need to call bx_import for system classes
+ */
+function bx_autoload($sClassName)
+{
+    if (0 === strncmp($sClassName, 'BxDol', 5) || 0 === strncmp($sClassName, 'BxBase', 6) || 0 === strncmp($sClassName, 'BxTempl', 7))
+        bx_import($sClassName);
+}
+
+/**
+ * Gets an instance of class pathing necessary parameters if it's necessary.
+ *
+ * @param string $sClassName class name.
+ * @param array $aParams an array of parameters to be pathed to the constructor of the class.
+ * @param array $mixedModule an array with module description. Is used when the requested class is located in some module.
+ * @return unknown
+ */
+function bx_instance($sClassName, $aParams = array(), $mixedModule = array())
+{
+    if(isset($GLOBALS['bxDolClasses'][$sClassName]))
+        return $GLOBALS['bxDolClasses'][$sClassName];
+
+    if ($mixedModule) {
+        if (!is_array($mixedModule)) {
+            $o = BxDolModule::getInstance($mixedModule);
+            $mixedModule = $o->_aModule;
+        }
+        $sClassName = bx_ltrim_str($sClassName, $mixedModule['class_prefix']);
+        bx_import($sClassName, $mixedModule);
+        $sClassName = $mixedModule['class_prefix'] . $sClassName;
+    }
+
+    $oClass = new ReflectionClass($sClassName);
+
+    $GLOBALS['bxDolClasses'][$sClassName] = empty($aParams) ? $oClass->newInstance() : $oClass->newInstanceArgs($aParams);
+
+    return $GLOBALS['bxDolClasses'][$sClassName];
+}
+
+
+/**
+ * Escapes string/array ready to pass to js script with filtered symbols like ', " etc
+ *
+ * @param $mixedInput - string/array which should be filtered
+ * @param $iQuoteType - string escaping method: BX_ESCAPE_STR_AUTO(default), BX_ESCAPE_STR_APOS or BX_ESCAPE_STR_QUOTE
+ * @return converted string / array
+ */
+function bx_js_string ($mixedInput, $iQuoteType = BX_ESCAPE_STR_AUTO)
+{
+    $aUnits = array(
+        "\n" => "\\n",
+        "\r" => "",
+    );
+    if (BX_ESCAPE_STR_APOS == $iQuoteType) {
+        $aUnits["'"] = "\\'";
+        $aUnits['<script'] = "<scr' + 'ipt";
+        $aUnits['</script>'] = "</scr' + 'ipt>";
+    } elseif (BX_ESCAPE_STR_QUOTE == $iQuoteType) {
+        $aUnits['"'] = '\\"';
+        $aUnits['<script'] = '<scr" + "ipt';
+        $aUnits['</script>'] = '</scr" + "ipt>';
+    } else {
+        $aUnits['"'] = '&quot;';
+        $aUnits["'"] = '&apos;';
+        $aUnits["<script>"] = '&lt;script&gt;';
+        $aUnits["</script>"] = '&lt;/script&gt;';
+    }
+    return str_replace(array_keys($aUnits), array_values($aUnits), $mixedInput);
+}
+
+/**
+ * Return input string/array ready to pass to html attribute with filtered symbols like ', " etc
+ *
+ * @param $mixedInput - string/array which should be filtered
+ * @param $iQuoteType - string escaping BX_ESCAPE_STR_AUTO, BX_ESCAPE_STR_APOS or BX_ESCAPE_STR_QUOTE
+ * @return converted string / array
+ */
+function bx_html_attribute ($mixedInput, $iQuoteType = BX_ESCAPE_STR_AUTO)
+{
+    $aUnits = array ();
+    if (BX_ESCAPE_STR_APOS == $iQuoteType)
+        $aUnits["'"] = "\\'";
+    elseif (BX_ESCAPE_STR_QUOTE == $iQuoteType)
+        $aUnits['"'] = '&quot;';
+    else
+        $aUnits = array("\"" => "&quot;", "'" => "&apos;");
+
+    return str_replace(array_keys($aUnits), array_values($aUnits), $mixedInput);
+}
+
+/**
+ * Escapes string/array ready to pass to php script with filtered symbols like ', " etc
+ *
+ * @param mixed $mixedInput - string/array which should be filtered
+ * @return converted string / array
+ */
+function bx_php_string_apos ($mixedInput)
+{
+    return str_replace("'", "\\'", $mixedInput);
+}
+function bx_php_string_quot ($mixedInput)
+{
+    return str_replace('"', '\\"', $mixedInput);
+}
+
+/**
+ * Gets file contents by URL.
+ *
+ * @param string $sFileUrl - file URL to be read.
+ * @param array $aParams - an array of parameters to be pathed with URL.
+ * @param string $sMethod - post or get.
+ * @param array $aHeaders - custom headers.
+ * @param string $sHttpCode - HTTP code to return
+ * @param array $aBasicAuth - array with 'user' and 'password' for Basic HTTP Auth
+ * @return string the file's contents.
+ */
+function bx_file_get_contents($sFileUrl, $aParams = array(), $sMethod = 'get', $aHeaders = array(), &$sHttpCode = null, $aBasicAuth = array(), $iTimeout = 0, $aCustomCurlParams = array())
+{
+    if(!in_array($sMethod, ['post', 'post-raw', 'post-json', 'post-json-object']))
+    	$sFileUrl = bx_append_url_params($sFileUrl, $aParams);
+
+    $sResult = '';
+    if(function_exists('curl_init')) {
+        $rConnect = curl_init();
+
+        curl_setopt($rConnect, CURLOPT_USERAGENT, 'UNA ' . (defined('BX_DOL_VERSION') ? constant('BX_DOL_VERSION') : ''));
+        curl_setopt($rConnect, CURLOPT_TIMEOUT, BxDolDb::getInstance() ? getParam('sys_default_curl_timeout') : 10);
+        curl_setopt($rConnect, CURLOPT_URL, $sFileUrl);
+        curl_setopt($rConnect, CURLOPT_RETURNTRANSFER, 1);
+
+        if (0 !== $iTimeout) {
+            curl_setopt($rConnect, CURLOPT_CONNECTTIMEOUT, $iTimeout);
+            curl_setopt($rConnect, CURLOPT_TIMEOUT, $iTimeout);
+        }
+        
+        if(getParam('sys_curl_ssl_allow_untrusted') == 'on'){
+            curl_setopt($rConnect, CURLOPT_SSL_VERIFYPEER, false);
+            curl_setopt($rConnect, CURLOPT_SSL_VERIFYHOST, false);
+        }
+
+        if (!ini_get('open_basedir'))
+            curl_setopt($rConnect, CURLOPT_FOLLOWLOCATION, 1);
+
+        if ($aBasicAuth)
+            curl_setopt($rConnect, CURLOPT_USERPWD, $aBasicAuth['user'] . ':' . $aBasicAuth['password']);
+
+        if ('post' == $sMethod) {
+            curl_setopt($rConnect, CURLOPT_POST, true);
+            curl_setopt($rConnect, CURLOPT_POSTFIELDS, http_build_query($aParams));
+        }
+        elseif ('post-raw' == $sMethod) {
+            curl_setopt($rConnect, CURLOPT_POST, true);
+            curl_setopt($rConnect, CURLOPT_POSTFIELDS, $aParams);
+        }
+        elseif ('post-json' == $sMethod) {
+            curl_setopt($rConnect, CURLOPT_POST, true);
+            curl_setopt($rConnect, CURLOPT_POSTFIELDS, json_encode($aParams));
+            $aHeaders[] = 'Content-Type: application/json';
+        }
+        elseif ('post-json-object' == $sMethod) {
+            curl_setopt($rConnect, CURLOPT_POST, true);
+            curl_setopt($rConnect, CURLOPT_POSTFIELDS, json_encode($aParams, JSON_FORCE_OBJECT));
+            $aHeaders[] = 'Content-Type: application/json';
+        }
+        elseif ($sMethod != 'get') {
+            curl_setopt($rConnect, CURLOPT_CUSTOMREQUEST, $sMethod);
+        }
+
+        if ($aHeaders)
+            curl_setopt($rConnect, CURLOPT_HTTPHEADER, $aHeaders);
+
+        if (defined('BX_DOL_URL_ROOT') && 0 === strpos($sFileUrl, BX_DOL_URL_ROOT)) {
+            $sAllCookies = '';
+            foreach($_COOKIE as $sKey => $mValue){
+                if(is_array($mValue)){
+                    foreach ($mValue as $k => $v)
+                        $sAllCookies .= "{$sKey}[{$k}]={$v};";
+                }
+                else{
+                    $sAllCookies .= $sKey . '=' . $mValue . ';';
+                }
+            }
+            curl_setopt($rConnect, CURLOPT_COOKIE, $sAllCookies);
+        }
+
+        if ($aCustomCurlParams)
+            foreach ($aCustomCurlParams as $sName => $mixedValue)
+                curl_setopt($rConnect, $sName, $mixedValue);
+
+        $sResult = curl_exec($rConnect);
+
+        if (curl_errno($rConnect) == 60) { // CURLE_SSL_CACERT
+            curl_setopt($rConnect, CURLOPT_CAINFO, BX_DIRECTORY_PATH_PLUGINS . 'curl/cacert/cacert.pem');
+            $sResult = curl_exec($rConnect);
+        }
+
+        if (NULL !== $sHttpCode)
+            $sHttpCode = curl_getinfo($rConnect, CURLINFO_HTTP_CODE);
+
+        curl_close($rConnect);
+    }
+    else {
+
+        $iSaveTimeout = false;
+        if (0 !== $iTimeout) {
+            $iSaveTimeout = ini_get('default_socket_timeout');
+            ini_set('default_socket_timeout', $iTimeout);
+        }
+
+        $sResult = @file_get_contents($sFileUrl);
+
+        if (0 !== $iTimeout && false !== $iSaveTimeout) {
+            ini_set('default_socket_timeout', $iSaveTimeout);
+        }
+    }
+
+    return $sResult;
+}
+
+function bx_make_utf8($s)
+{
+    // Test it and see if it is UTF-8 or not
+    if (false !== mb_detect_encoding($s, ["UTF-8"], true))
+        return $s;
+
+    $sEncoding = mb_detect_encoding($s, ['ASCII', 'BASE64', 'HTML-ENTITIES', 'Windows-1251', 'KOI8-R'], true);
+    if ($sEncoding === false)
+        return $s;
+
+    return mb_convert_encoding($s, "UTF-8", $sEncoding);
+}
+
+function bx_get_site_info($sSourceUrl, $aProcessAdditionalTags = array())
+{
+    $aResult = array();
+    $sContent = bx_file_get_contents($sSourceUrl);
+
+    if ($sContent) {
+        $sCharset = '';
+        preg_match("/<meta.+charset=([A-Za-z0-9-]+).+>/i", $sContent, $aMatch);
+        if (isset($aMatch[1]))
+            $sCharset = $aMatch[1];
+
+        $sContent = preg_replace("/<script[^>]*>(.*?)<\/script>/i", '', $sContent);
+        $sContent = preg_replace("/<style[^>]*>(.*?)<\/style>/i", '', $sContent);
+        if (preg_match("/<title[^>]*>(.*?)<\/title>/i", $sContent, $aMatch))
+            $aResult['title'] = bx_make_utf8(strip_tags($aMatch[1]), $sCharset);
+        else
+            $aResult['title'] = parse_url($sSourceUrl, PHP_URL_HOST);
+
+        $aResult['description'] = bx_make_utf8(bx_parse_html_tag($sContent, 'meta', 'name', 'description', 'content', $sCharset), $sCharset);
+        $aResult['keywords'] = bx_make_utf8(bx_parse_html_tag($sContent, 'meta', 'name', 'keywords', 'content', $sCharset), $sCharset);
+
+        if ($aProcessAdditionalTags) {
+
+            foreach ($aProcessAdditionalTags as $k => $a) {
+                $aResult[$k] = bx_parse_html_tag(
+                    $sContent, 
+                    isset($a['tag']) ? $a['tag'] : 'meta', 
+                    isset($a['name_attr']) ? $a['name_attr'] : 'itemprop', 
+                    isset($a['name']) ? $a['name'] : $k, 
+                    isset($a['content_attr']) ? $a['content_attr'] : 'content', 
+                    $sCharset,
+                    isset($a['specialchars_decode']) ? $a['specialchars_decode'] : true); 
+
+                if ((isset($a['name']) && 'og:image' == $a['name']) || (isset($a['tag']) && 'link' == $a['tag'])) {
+                    $aResult[$k] = bx_get_site_info_fix_relative_url ($sSourceUrl, $aResult[$k]);
+                }
+            }
+
+        }
+    }
+
+    return $aResult;
+}
+
+/**
+ * Fix relative URL to make it absolute
+ * @param $sSourceUrl main URL
+ * @param $s URL to fix
+ * @return absolute URL or URL wothout changes
+ */ 
+function bx_get_site_info_fix_relative_url ($sSourceUrl, $s)
+{
+    if (0 === stripos($s, 'http://') || 0 === stripos($s, 'https://') || 0 === stripos($s, 'ftp://') || !$s)
+        return $s;
+
+    if ('/' == $s[0]) {
+        $a = parse_url($sSourceUrl);
+        return $a['scheme'] . '://' . $a['host'] . (empty($a['port']) || 80 == $a['port'] || 443 == $a['port'] ? '' : ':' . $a['port']) . $s;
+    }
+
+    return $sSourceUrl . ('/' === substr($sSourceUrl, -1) ? $s : '/' . $s);
+}
+
+function bx_parse_html_tag ($sContent, $sTag, $sAttrNameName, $sAttrNameValue, $sAttrContentName, $sCharset = false, $bSpecialCharsDecode = true)
+{
+    // This regex is designed to be more flexible with attribute orders and spacing
+    $regex = "/<{$sTag}\s+(?:[^>]*?\s+)?{$sAttrNameName}\s*=\s*['\"]?{$sAttrNameValue}['\"]?\s+[^>]*{$sAttrContentName}\s*=\s*['\"]([^'\"]*)['\"][^>]*>/i";
+    if (!preg_match($regex, $sContent, $aMatch)) {
+        // Try reversing the attribute order in case the first regex fails
+        $regex = "/<{$sTag}\s+(?:[^>]*?\s+)?{$sAttrContentName}\s*=\s*['\"]([^'\"]*)['\"]\s+[^>]*{$sAttrNameName}\s*=\s*['\"]?{$sAttrNameValue}['\"]?[^>]*>/i";
+        preg_match($regex, $sContent, $aMatch);
+    }
+
+    // Extract the attribute content if available
+    $s = isset($aMatch[1]) ? $aMatch[1] : '';
+
+    // Optionally convert character encoding
+    if ($s && $sCharset) {
+        $s = mb_convert_encoding($s, 'UTF-8', $sCharset);
+    }
+
+    // Optionally decode special HTML characters
+    if ($bSpecialCharsDecode) {
+        $s = htmlspecialchars_decode($s);
+    }
+
+    return $s;
+}
+
+// calculation ini_get('upload_max_filesize') in bytes as example
+function return_bytes($val)
+{
+    $val = trim($val);
+    $last = strtolower($val[strlen($val)-1]);
+    $val = (int)$val;
+    switch($last) {
+        // The 'G' modifier is available since PHP 5.1.0
+        case 'k':
+            $val *= 1024;
+            break;
+        case 'm':
+            $val *= 1024 * 1024;
+            break;
+        case 'g':
+            $val *= 1024 * 1024 * 1024;
+            break;
+    }
+    return $val;
+}
+
+// Generate Random Password
+function genRndPwd($iLength = 8, $bSpecialCharacters = true)
+{
+    $sPassword = '';
+    $sChars = "abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+
+    if($bSpecialCharacters === true)
+        $sChars .= "!?=/&+,.";
+
+    for($i = 0; $i < $iLength; $i++) {
+        $x = mt_rand(0, strlen($sChars) -1);
+        $sPassword .= $sChars[$x];
+    }
+
+    return $sPassword;
+}
+
+// Generate Random Salt for Password encryption
+function genRndSalt()
+{
+    return genRndPwd(8, true);
+}
+
+/**
+ * Encrypt user password.
+ * When BX_PWD_ALGO is 'custom', then bx_custom_pwd_encrypt function with custom algorythm must be defined in inc/header.inc.php file.
+ * @param $sPwd clear password
+ * @param $sSalt salt
+ * @return password hash
+ */ 
+function encryptUserPwd($sPwd, $sSalt)
+{
+	$sAlgo = defined('BX_PWD_ALGO') ? BX_PWD_ALGO : '';
+
+    switch ($sAlgo) {
+        
+        case 'custom':
+            return bx_custom_pwd_encrypt($sPwd, $sSalt); 
+
+    	case 'crypt':
+            return crypt($sPwd, BX_PWD_ALGO_SALT);
+
+        case 'sha1_crypt_salt':
+            return sha1(crypt($sPwd, BX_PWD_ALGO_SALT) . $sSalt);
+
+        case 'sha1_md5_salt':
+        default:
+            return sha1(md5($sPwd) . $sSalt);
+    }
+}
+
+/**
+ * Hash profile ID
+ */
+function encryptUserId($sId)
+{
+    if (!($oProfile = BxDolProfile::getInstance($sId)))
+        return false;
+
+    if (!($oAccount = $oProfile->getAccountObject()))
+        return false;
+
+    $aAccountInfo = $oAccount->getInfo();
+    return sha1(md5($sId) . md5($aAccountInfo['salt']) . BX_DOL_SECRET);
+}
+
+function bx_get_reset_password_key($sValue, $sField = 'email', $iLifetime = 0)
+{
+    if(empty($iLifetime)) {
+        $iLifetime = (int)getParam('sys_account_reset_password_key_lifetime');
+        if(empty($iLifetime)) 
+            $iLifetime = 86400;
+    }
+
+    $oKey = BxDolKey::getInstance();
+    if(!$oKey)
+        return false;
+
+    return $oKey->getNewKey(array($sField => $sValue), $iLifetime);
+}
+
+function bx_get_reset_password_link($sValue, $sField = 'email', $iLifetime = 0)
+{
+    $sKey = bx_get_reset_password_key($sValue, $sField, $iLifetime);
+    if(!$sKey)
+        return false;
+
+    return bx_get_reset_password_link_by_key($sKey);
+}
+
+function bx_get_reset_password_link_by_key($sKey)
+{
+    return bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=forgot-password', array('key' => $sKey)));
+}
+
+function bx_get_reset_password_redirect($iAccountId)
+{
+    $sResult = '';
+    $sRedirect = getParam('sys_account_reset_password_redirect');
+    switch($sRedirect) {
+        case 'home':
+            $sResult = BxDolPermalinks::getInstance()->permalink('page.php?i=home');
+            break;
+        
+        case 'profile':
+        case 'profile_edit':
+            if(empty($iAccountId))
+                break;
+            
+            $oAccount = BxDolAccount::getInstance($iAccountId);
+            if(!$oAccount)
+                break;
+            
+            $aAccountInfo = $oAccount->getInfo();
+            if(empty($aAccountInfo) || !is_array($aAccountInfo) || empty($aAccountInfo['profile_id']))
+                break;
+
+            $oProfile = BxDolProfile::getInstance((int)$aAccountInfo['profile_id']);
+            if(!$oProfile)
+                break;
+
+            $sResult = $oProfile->{'get' . ($sRedirect == 'profile_edit' ? 'Edit' : '') . 'Url'}();
+            break;
+
+        case 'custom':
+            $sResult = getParam('sys_account_reset_password_redirect_custom');
+            break;
+    }
+
+    if(!empty($sResult))
+        $sResult = bx_absolute_url($sResult);
+
+    return $sResult;
+}
+
+function bx_get ($sName, $sMethod = false)
+{
+    if (isset($_GET[$sName]) && ('get' == $sMethod || !$sMethod))
+        return $_GET[$sName];
+    elseif (isset($_POST[$sName]) && ('post' == $sMethod || !$sMethod))
+        return $_POST[$sName];
+    else
+        return false;
+}
+
+function bx_set ($sName, $sValue, $sMethod = false)
+{
+    if(!$sMethod)
+        $sMethod = 'get';
+
+    $bResult = true;
+    switch($sMethod) {
+        case 'get':
+            $_GET[$sName] = $sValue;
+            break;
+        
+        case 'post':
+            $_POST[$sName] = $sValue;
+            break;
+
+        default:
+            $bResult = false;
+    }
+
+    return $bResult;
+}
+
+function bx_get_with_prefix ($sPrefix, $sMethod = false)
+{
+    $aSources = array('get' => &$_GET, 'post' => &$_POST);
+
+    $aFiltered = array();
+    foreach($aSources as $sName => $aSource)
+        if($sMethod == $sName || !$sMethod)
+            $aFiltered = array_merge($aFiltered, array_filter($aSource, function($sKey) use ($sPrefix) {
+                return strpos($sKey, $sPrefix) === 0;
+            }, ARRAY_FILTER_USE_KEY));
+
+    $aUpdated = array();
+    array_walk($aFiltered, function($sValue, $sKey) use ($sPrefix, &$aUpdated) {
+        $aUpdated[trim(str_replace($sPrefix, '', $sKey), '_-')] = $sValue;
+    });
+
+    return $aUpdated;
+}
+
+function bx_get_base_url_inline($aParams = array())
+{
+    $aBaseLink = parse_url(BX_DOL_URL_ROOT);
+    $sPageLink = (!empty($aBaseLink['scheme']) ? $aBaseLink['scheme'] : 'http') . '://' . $aBaseLink['host'] . (!empty($aBaseLink['port']) ? ':' . $aBaseLink['port'] : '');
+    if(!empty($_SERVER['REQUEST_URI']))
+        $sPageLink .= $_SERVER['REQUEST_URI'];
+
+    list($sPageLink, $aPageParams) = bx_get_base_url($sPageLink);
+
+    if(!empty($_SERVER['QUERY_STRING'])) {
+        $aPageParamsAdd = array();
+        parse_str($_SERVER['QUERY_STRING'], $aPageParamsAdd);
+        if(!empty($aPageParamsAdd) && is_array($aPageParamsAdd))
+            $aPageParams = array_merge($aPageParams, $aPageParamsAdd);
+    }
+
+    if(!empty($aParams) && is_array($aParams))
+        $aPageParams = array_merge($aPageParams, $aParams);
+
+    return array($sPageLink, $aPageParams);
+}
+
+function bx_get_base_url_popup($aParams = array())
+{
+    list($sPageLink, $aPageParams) = bx_get_base_url($_SERVER['HTTP_REFERER']);
+
+    if(!empty($aParams) && is_array($aParams))
+        $aPageParams = array_merge($aPageParams, $aParams);
+
+    return array($sPageLink, $aPageParams);
+}
+
+function bx_get_base_url($sPageLink)
+{
+    $sPageLink = BxDolPermalinks::getInstance()->unpermalink($sPageLink, false);
+
+    $sPageParams = '';
+    if(strpos($sPageLink, '?') !== false)
+        list($sPageLink, $sPageParams) = explode('?', $sPageLink);
+
+    $aPageParams = array();
+    if(!empty($sPageParams))
+        parse_str($sPageParams, $aPageParams);
+
+    return array($sPageLink, $aPageParams);
+}
+
+function bx_get_page_info()
+{
+    list($sPageLink, $aPageParams) = bx_get_base_url_inline();
+
+    if(isset($aPageParams['i'], $aPageParams['id']) && ($oPage = BxDolPage::getObjectInstanceByURI($aPageParams['i'])) !== false) {
+        $sPageModule = $oPage->getModule();
+        if(bx_srv('system', 'is_module_context', [$sPageModule]) && ($oProfile = BxDolProfile::getInstanceByContentAndType($aPageParams['id'], $sPageModule)) !== false)
+            return [
+                'context_module' => $sPageModule, 
+                'context_id' => $aPageParams['id'], 
+                'context_profile_id' => $oProfile->id()
+            ];
+    }
+
+    if(isset($aPageParams['profile_id']) && ($oProfile = BxDolProfile::getInstance($aPageParams['profile_id'])) !== false) {
+        $sProfileModule = $oProfile->getModule();
+        if(bx_srv('system', 'is_module_context', [$sProfileModule]))
+            return [
+                'context_module' => $sProfileModule, 
+                'context_id' => $oProfile->getContentId(), 
+                'context_profile_id' => $aPageParams['profile_id']
+            ];
+    }
+
+    return false;
+}
+
+function bx_get_location_bounds_latlng($fLatitude, $fLongitude, $iRadiusInKm)
+{
+    $fEquatorLatInKm = 111.321;
+    $aRv = array();
+    $aRv['max_lat'] = $fLatitude + $iRadiusInKm / $fEquatorLatInKm;
+    $aRv['min_lat'] = $fLatitude - ($aRv['max_lat'] - $fLatitude);
+    $aRv['max_lng'] = $fLongitude + $iRadiusInKm / (cos($aRv['min_lat'] * M_PI / 180) * $fEquatorLatInKm);
+    $aRv['min_lng'] = $fLongitude - ($aRv['max_lng'] - $fLongitude);
+    return $aRv;
+    
+}
+
+function bx_encode_url_params ($a, $aExcludeKeys = array (), $aOnlyKeys = false)
+{
+    $s = '';
+    foreach ($a as $sKey => $sVal) {
+        if (in_array($sKey, $aExcludeKeys))
+            continue;
+        if (false !== $aOnlyKeys && !in_array($sKey, $aOnlyKeys))
+            continue;
+        if (is_array($sVal)) {
+            foreach ($sVal as $sSubVal) {
+                $s .= rawurlencode($sKey) . '[]=' . rawurlencode(is_array($sSubVal) ? 'array' : $sSubVal) . '&';
+            }
+        } else {
+            $s .= rawurlencode($sKey) . '=' . rawurlencode($sVal) . '&';
+        }
+    }
+    return $s;
+}
+
+/**
+ * It works similar to `parse_str` php function, but it doesn't decode URL params
+ */
+function bx_parse_str($s) 
+{
+    $a = [];
+    $aPairs = explode('&', $s);
+
+    foreach ($aPairs as $i) {
+        list($sName, $mixedValue) = explode('=', $i, 2);
+        $sName = rtrim($sName, '[]');
+
+        if (isset($a[$sName])) {
+            if (is_array($a[$sName]))
+                $a[$sName][] = $mixedValue;
+            else 
+                $a[$sName] = [$a[$sName], $mixedValue];
+        }
+        else {
+            $a[$sName] = $mixedValue;
+        }
+    }
+
+    return $a;
+}
+
+function bx_append_url_params ($sUrl, $mixedParams, $bEncodeParams = true, $aIgnoreParams = [])
+{
+    if (!$mixedParams)
+        return $sUrl;
+
+    $sParams = false === strpos($sUrl, '?') ? '?' : '&';
+
+    if (is_array($mixedParams)) {
+        foreach($mixedParams as $sKey => $sValue) {
+            if (!is_array($sValue)) {
+                if ($bEncodeParams) {
+                    if ($sKey && !in_array($sKey, $aIgnoreParams))
+                        $sKey = rawurlencode($sKey);
+                    if ($sValue && !in_array($sValue, $aIgnoreParams))
+                        $sValue = rawurlencode($sValue);
+                }
+                $sParams .= $sKey . '=' . $sValue . '&';
+            }
+            else {
+                foreach($sValue as $sSubValue) {
+                    if ($bEncodeParams) {
+                        if ($sKey && !in_array($sKey, $aIgnoreParams))
+                            $sKey = rawurlencode($sKey);
+                        if ($sSubValue && !in_array($sSubValue, $aIgnoreParams))
+                            $sSubValue = rawurlencode($sSubValue);
+                    }
+                    $sParams .= $sKey . '[]=' . $sSubValue . '&';
+                }
+            }
+        }
+        $sParams = substr($sParams, 0, -1);
+    } else {
+        $sParams .= $mixedParams;
+    }
+    return $sUrl . $sParams;
+}
+
+function bx_process_url_param($sValue, $sPattern = "/^[\d\w_-]+$/")
+{
+    $mixedValue = bx_process_input($sValue);
+    return $mixedValue !== false && preg_match($sPattern, $mixedValue) ? $mixedValue : '';
+}
+
+function bx_rrmdir($directory)
+{
+    if (substr($directory,-1) == "/")
+        $directory = substr($directory,0,-1);
+
+    if (!file_exists($directory) || !is_dir($directory))
+        return false;
+    elseif (!is_readable($directory))
+        return false;
+
+    if (!($directoryHandle = opendir($directory)))
+        return false;
+
+    while ($contents = readdir($directoryHandle)) {
+        if ($contents != '.' && $contents != '..') {
+            $path = $directory . "/" . $contents;
+
+            if (is_dir($path))
+                bx_rrmdir($path);
+            else
+                unlink($path);
+        }
+    }
+
+    closedir($directoryHandle);
+
+    if (!rmdir($directory))
+        return false;
+
+    return true;
+}
+
+function bx_clear_folder ($sPath, $aExts = array ())
+{
+    if (substr($$sPath,-1) == "/")
+        $sPath = substr($sPath,0,-1);
+
+    if (!file_exists($sPath) || !is_dir($sPath))
+        return false;
+    elseif (!is_readable($sPath))
+        return false;
+
+    if (!($h = opendir($sPath)))
+        return false;
+
+    while ($sFile = readdir($h)) {
+        if ('.' == $sFile || '..' == $sFile)
+            continue;
+
+        $sFullPath = $sPath . '/' . $sFile;
+
+        if (is_dir($sFullPath))
+            continue;
+
+        if (!$aExts || (($sExt = pathinfo($sFullPath, PATHINFO_EXTENSION)) && in_array($sExt, $aExts)))
+            @unlink($sFullPath);
+    }
+
+    closedir($h);
+
+    return true;
+}
+
+function bx_ltrim_str ($sString, $sPrefix, $sReplace = '')
+{
+    if ($sReplace && substr($sString, 0, strlen($sReplace)) == $sReplace)
+        return $sString;
+    if (substr($sString, 0, strlen($sPrefix)) == $sPrefix)
+        return $sReplace . substr($sString, strlen($sPrefix));
+    return $sString;
+}
+
+function bx_rtrim_str ($sString, $sPrefix, $sReplace = '')
+{
+    if ($sReplace && substr($sString, -strlen($sReplace)) == $sReplace)
+        return $sString;
+    if (substr($sString, -strlen($sPrefix)) == $sPrefix)
+        return substr($sString, 0, -strlen($sPrefix)) . $sReplace;
+    return $sString;
+}
+
+/**
+ * Strip all lines with no information for example: <p></p>, <br /><br />
+ */ 
+function bx_trim_nl_duplicates($s)
+{
+    $sStrip = implode('', array_keys(get_html_translation_table(HTML_ENTITIES)));
+    return implode('', array_filter(mb_split("[\n\r]", $s), function($s) use ($sStrip) {
+        return trim(strip_tags($s, '<button><canvas><embed><hr><iframe><img><input><object><select><svg><video>'), $sStrip) !== '';
+    }));
+}
+
+/**
+ * Convert array to attributes string
+ *
+ * Example:
+ * @code
+ * $a = array('name' => 'test', 'value' => 5);
+ * $s = bx_convert_array2attrs($a);
+ * echo $s; // outputs: name="test" value="5"
+ * @endcode
+ *
+ * @param $a - array of attributes
+ * @param $sClasses - classes to merge with 'class' attribute
+ * @param $sStyles - styles to merge with 'style' attribute
+ * @return string
+ */
+function bx_convert_array2attrs ($a, $sClasses = false, $sStyles = false)
+{
+    $sRet = '';
+
+    if (!$a || !is_array($a))
+        $a = array();
+
+    if ($sClasses) {
+        $sClasses = trim($sClasses);
+        $a['class'] = $sClasses . (!empty($a['class']) ? ' ' . $a['class'] : '');
+    }
+
+    if ($sStyles) {
+        $sStyles = trim($sStyles);
+        if (';' != $sStyles[strlen($sStyles)-1])
+            $sStyles .= ';';
+        $a['style'] = $sStyles . (!empty($a['style']) ? ' ' . $a['style'] : '');
+    }
+
+    foreach ($a as $sKey => $sValue) {
+        if(is_null($sValue)) // pass NULL values
+            continue;
+
+        $sValueC = bx_html_attribute($sValue, BX_ESCAPE_STR_QUOTE);
+
+        $sRet .= " $sKey=\"$sValueC\"";
+    }
+
+    return $sRet;
+}
+
+function bx_unicode_urldecode($s)
+{
+    preg_match_all('/%u([[:alnum:]]{4})/', $s, $a);
+
+    foreach ($a[1] as $uniord) {
+        $dec = hexdec($uniord);
+        $utf = '';
+
+        if ($dec < 128) {
+            $utf = chr($dec);
+        } else if ($dec < 2048) {
+            $utf = chr(192 + (($dec - ($dec % 64)) / 64));
+            $utf .= chr(128 + ($dec % 64));
+        } else {
+            $utf = chr(224 + (($dec - ($dec % 4096)) / 4096));
+            $utf .= chr(128 + ((($dec % 4096) - ($dec % 64)) / 64));
+            $utf .= chr(128 + ($dec % 64));
+        }
+
+        $s = str_replace('%u'.$uniord, $utf, $s);
+    }
+
+    return urldecode($s);
+}
+
+/**
+ * Raise an audit event
+ * @param int $iContentId - content id
+ * @param string $sContentModule - module name
+ * @param string $sAction - system action key
+ * @param array $aParams - array of parameters 
+ */
+function bx_audit($iContentId, $sContentModule, $sAction, $aParams, $iProfileId = 0)
+{
+    if (!getParam('sys_audit_enable') || getParam('sys_audit_acl_levels') == '')
+        return;
+    
+    if ($iProfileId == 0)
+        $iProfileId = bx_get_logged_profile_id();
+    
+    if (!BxDolAcl::getInstance()->isMemberLevelInSet(explode(',', getParam('sys_audit_acl_levels')), $iProfileId))
+        return;
+    
+    $sContentTitle = $sContentInfoObject = $sContextProfileTitle = $sData = $sActionParams = '';
+    $iContextProfileId = 0;
+    
+    if (isset($aParams['profile_id']))
+        $iProfileId = (int)$aParams['profile_id'];
+    
+    if (isset($aParams['content_title']))
+        $sContentTitle = $aParams['content_title'];
+    
+    if (isset($aParams['context_profile_id']))
+        $iContextProfileId = (int)$aParams['context_profile_id'];
+    
+    if (isset($aParams['context_profile_title']))
+        $sContextProfileTitle = $aParams['context_profile_title'];
+    
+    if (isset($aParams['action_params']) && is_array($aParams['action_params']) && count($aParams['action_params']))
+        $sActionParams = serialize($aParams['action_params']);
+    
+    if (isset($aParams['content_info_object']))
+        $sContentInfoObject = $aParams['content_info_object'];
+     
+    if (isset($aParams['data']) && is_array($aParams['data']) && count($aParams['data']))
+        $sData = serialize($aParams['data']);
+    
+    $sProfileTitle = BxDolProfile::getInstance($iProfileId)->getDisplayName();
+    
+    $oDb = BxDolDb::getInstance();
+    $sSql = $oDb->prepare("INSERT INTO `sys_audit`(`added`, `profile_id`, `profile_title`, `content_id`, `content_title`, `content_module`, `context_profile_id`, `context_profile_title`, `action_lang_key`, `action_lang_key_params`, `content_info_object`, `extras`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
+     ,time(), $iProfileId, $sProfileTitle, $iContentId, $sContentTitle, $sContentModule, $iContextProfileId, $sContextProfileTitle, $sAction, $sActionParams ,$sContentInfoObject, $sData);
+    return !$oDb->query($sSql);
+}
+
+/**
+ * Raise an alert
+ * @param string $sUnit - system type
+ * @param string $sAction - system action
+ * @param int $iObjectId - object id
+ * @param int $iSender - sender (action's author) profile id, if it is false - then currectly logged in profile id is used
+ * @param array $aExtras - extra parameters 
+ */
+function bx_alert($sUnit, $sAction, $iObjectId, $iSender = false, $aExtras = array())
+{
+    $o = new BxDolAlerts($sUnit, $sAction, $iObjectId, $iSender, $aExtras);
+    $o->alert();
+}
+
+/**
+ * Check whether serice method exists or not
+ * @param $mixed module name or module id
+ * @param $sMethod service method name in format 'method_name', corresponding class metod is serviceMethodName
+ * @param $sClass class to search for service method, by default it is main module class
+ * @return boolean check result
+ */
+function bx_is_srv($mixedModule, $sMethod, $sClass = "Module", $bIgnoreInactive = false)
+{
+    return BxDolRequest::serviceExists($mixedModule, $sMethod, $sClass, $bIgnoreInactive);
+}
+
+/**
+ * Check whether serice method exists or not in 'Ignore Inactive' mode.
+ */
+function bx_is_srv_ii($mixedModule, $sMethod, $sClass = "Module")
+{
+    return BxDolRequest::serviceExists($mixedModule, $sMethod, $sClass, true);
+}
+
+/**
+ * Perform serice call
+ * @param $mixed module name or module id
+ * @param $sMethod service method name in format 'method_name', corresponding class metod is serviceMethodName
+ * @param $aParams params to pass to service method
+ * @param $sClass class to search for service method, by default it is main module class
+ * @return service call result
+ */
+function bx_srv($mixed, $sMethod, $aParams = array(), $sClass = 'Module', $bIgnoreCache = false, $bIgnoreInactive = false)
+{
+    return BxDolService::call($mixed, $sMethod, $aParams, $sClass, $bIgnoreCache, $bIgnoreInactive);
+}
+
+/**
+ * Perform serice call in 'Ignore Inactive' mode.
+ */
+function bx_srv_ii($mixed, $sMethod, $aParams = array(), $sClass = 'Module', $bIgnoreCache = false)
+{
+    return BxDolService::call($mixed, $sMethod, $aParams, $sClass, $bIgnoreCache, true);
+}
+
+/**
+ * Perform serice call in 'Ignore Cache' mode.
+ */
+function bx_srv_ic($mixed, $sMethod, $aParams = array(), $sClass = 'Module', $bIgnoreInactive = false)
+{
+    return BxDolService::call($mixed, $sMethod, $aParams, $sClass, true, $bIgnoreInactive);
+}
+
+function bx_replace_markers($mixed, $aMarkers)
+{
+    if (empty($mixed) || empty($aMarkers))
+        return $mixed;
+
+    if (is_array($mixed)) {
+        foreach ($mixed as $sKey => $sValue)
+            $mixed[$sKey] = bx_replace_markers ($sValue, $aMarkers);
+    } else {
+        foreach ($aMarkers as $sKey => $sValue)
+            $mixed = str_replace('{' . $sKey . '}', is_null($sValue) ? '' : $sValue, $mixed);
+    }
+
+    return $mixed;
+}
+
+function bx_site_hash($sSalt = '', $isSkipVersion = false)
+{
+    return md5($sSalt . ($isSkipVersion ? '' : bx_get_ver() . getParam('sys_revision')) . BX_DOL_SECRET . BX_DOL_URL_ROOT);
+}
+
+/**
+ * Transform string to method name string, for example it changes 'some_method' string to 'SomeMethod' string
+ * @param $s string where words are separated with underscore
+ * @param $aWordsDelimiter word delimeters
+ * @return string where every word begins with capital letter
+ */
+function bx_gen_method_name ($s, $aWordsDelimiter = array('_'))
+{
+    return str_replace(' ', '', ucwords(str_replace($aWordsDelimiter, ' ', $s)));
+}
+
+/**
+ * Trigger user error
+ * @param $sMsg message to display
+ * @param $iNumLevelsBack add additional debug backtracing N levels back
+ */
+function bx_trigger_error ($sMsg, $iNumLevelsBack = 0)
+{
+    $a = debug_backtrace();
+    $sMsgAdd = "<br />\n related code in <b>{$a[$iNumLevelsBack]['file']}</b> on line <b>{$a[$iNumLevelsBack]['line']}</b> <br />\n";
+    trigger_error ($sMsg . $sMsgAdd, E_USER_ERROR);
+}
+
+/**
+ * Get system DB version, for files version @see BX_DOL_VERSION, these versions must match
+ */
+function bx_get_ver ($bInvalidateCache = false)
+{
+    $oDb = BxDolDb::getInstance();
+
+    if ($bInvalidateCache)
+        $oDb->cleanMemory('sys_version');
+    
+    $sQuery = $oDb->prepare("SELECT `version` FROM `sys_modules` WHERE `name` = 'system'");
+    return $oDb->fromMemory('sys_version', 'getOne', $sQuery);
+}
+
+function bx_check_debug_mode() 
+{
+    if(!bx_is_dbg()) 
+        return;
+
+    ini_set('display_errors', 'On');
+    error_reporting(E_ALL);
+}
+
+/**
+ * Check if site maintetance mode is enabled.
+ * Maintetance mode is enabled when '.bx_maintenance' file exists in the script root folder, 
+ * please note that this is hidden file and some file managers don't show it.
+ * @param $bShowHttpError show 503 HTTP error if site is in mainenance mode
+ * @return true if site is in maintenance mode, or false otherwise
+ */
+function bx_check_maintenance_mode ($bShowHttpError = false)
+{
+    $bMaintetance = file_exists(BX_DIRECTORY_PATH_ROOT . BX_MAINTENANCE_FILE) && !defined('BX_DOL_UPGRADING');
+
+    if ($bMaintetance && $bShowHttpError)
+        bx_show_service_unavailable_error_and_exit ('Site is temporarily unavailable due to scheduled maintenance, please try again in a minute.', 600);
+
+    return $bMaintetance;
+}
+
+/**
+ * Check for minimal requirements.
+ * if BX_DISABLE_REQUIREMENTS_CHECK is defined then this requirements checking is skipped.
+ * @param $bShowHttpError show 503 HTTP error if site doesn't meet minimal requirements
+ * @return false if requirements are met, or array of errors of requirements aren't met
+ */
+function bx_check_minimal_requirements ($bShowHttpError = false)
+{
+    if (defined('BX_DISABLE_REQUIREMENTS_CHECK'))
+        return false;
+
+    $aErrors = array();
+
+    $aErrors[] = (ini_get('register_globals') == 0) ? '' : '<b>register_globals</b> is on (you need to disable it, or your site will be unsafe)';
+    $aErrors[] = (ini_get('safe_mode') == 0) ? '' : '<b>safe_mode</b> is on (you need to disable it)';
+    $aErrors[] = (version_compare(PHP_VERSION, '5.3.0', '<')) ? 'PHP version is too old (please update to <b>PHP 5.3.0</b> at least)' : '';
+    $aErrors[] = (!extension_loaded( 'mbstring')) ? '<b>mbstring</b> extension not installed (the script cannot work without it)' : '';
+    $aErrors[] = (ini_get('allow_url_include') == 0 || version_compare(PHP_VERSION, '8.0.0', '>=')) ? '' : '<b>allow_url_include</b> is on (you need to disable it, or your site will be unsafe)';
+
+    $aErrors = array_diff($aErrors, array('')); // delete empty
+
+    $bFailedMinimalRequirements = !empty($aErrors);
+
+    if ($bFailedMinimalRequirements && $bShowHttpError) {
+        $sErrors = implode(" <br /> ", $aErrors);
+        bx_show_service_unavailable_error_and_exit($sErrors);
+    }
+
+    return $bFailedMinimalRequirements ? $aErrors : false;
+}
+
+/**
+ * Check if redirect to the correct hostname is required, for example redirect from site.com to www.site.com
+ * @param $bProcessRedirect process redirect and exit if needed
+ */
+function bx_check_redirect_to_correct_hostname ($bProcessRedirect = false)
+{
+    $aUrl = parse_url(BX_DOL_URL_ROOT);
+    $iPortDefault = 'https' == $aUrl['scheme'] ? '443' : '80';
+
+    $bRedirectRequired = isset($_SERVER['HTTP_HOST']) && 0 !== strcasecmp($_SERVER['HTTP_HOST'], $aUrl['host']) && 0 !== strcasecmp($_SERVER['HTTP_HOST'], $aUrl['host'] . ':' . (!empty($aUrl['port']) ? $aUrl['port'] : $iPortDefault));
+
+    if ($bRedirectRequired && $bProcessRedirect) {
+        header("Location:" . bx_get_self_url(), true, 301);
+        exit;
+    }
+    
+    return $bRedirectRequired;
+}
+
+/**
+ * Get URL of current page
+ */
+function bx_get_self_url ()
+{
+    $aUrl = parse_url(BX_DOL_URL_ROOT);
+    $sPort = empty($aUrl['port']) || 80 == $aUrl['port'] || 443 == $aUrl['port'] ? '' : ':' . $aUrl['port'];
+    return "{$aUrl['scheme']}://{$aUrl['host']}{$sPort}{$_SERVER['REQUEST_URI']}";
+}
+
+/**
+ * Check if redirect to remove install folder.
+ * If BX_SKIP_INSTALL_CHECK is defined then this redirect checking is skipped.
+ * @param $bProcessRedirect process redirect and exit if needed
+ */
+function bx_check_redirect_to_remove_install_folder ($bProcessRedirect = false)
+{
+    $bRemoveInstallFolder = !defined ('BX_SKIP_INSTALL_CHECK') && file_exists(BX_DIRECTORY_PATH_ROOT . 'install');
+
+    if ($bRemoveInstallFolder && $bProcessRedirect) {
+        header('Location:' . BX_DOL_URL_ROOT . 'install/index.php?action=remove_install');
+        exit;
+    }
+
+    return $bRemoveInstallFolder;
+}
+
+/**
+ * Show HTTP 503 service unavailable error and exit
+ */
+function bx_show_service_unavailable_error_and_exit ($sMsg = false, $iRetryAfter = 86400)
+{
+    header('HTTP/1.0 503 Service Unavailable', true, 503);
+    header('Retry-After: 600');
+    echo $sMsg ? $sMsg : 'Service temporarily unavailable';
+    exit;
+}
+
+/**
+ * The function is sumilar to php readfile, but it send all required headers and can send file by chunks and suports file seek
+ * @param $sPath path to file to output to the browser
+ * @param $sFilename filename without path, ig file is saved from browser, then this name is used, not used(empty) by default
+ * @param $sMimeType file mime type, by default 'application/octet-stream'
+ * @param $iCacheAge file cache age, by default 0
+ * @param $sCachePrivacy cache privacy 'public' (default value) or 'private'
+ * @param $sContentDisposition content disposition header ('inline' or 'attachment' usually
+ * @return true on success or false on error
+ */
+function bx_smart_readfile($sPath, $sFilename = '', $sMimeType = 'application/octet-stream', $iCacheAge = 0, $sCachePrivacy = 'public', $sContentDisposition = 'inline')
+{
+    if (!file_exists($sPath))
+        return  false;
+
+    $fp = @fopen($sPath, 'rb');
+
+    $size   = filesize($sPath);
+    $length = $size;
+    $start  = 0;
+    $end    = $size - 1;
+
+    header('Content-Type: ' . $sMimeType);
+    header('Cache-Control: ' . $sCachePrivacy . ', must-revalidate, max-age=' . $iCacheAge);
+    header("Expires: " . gmdate('D, d M Y H:i:s', time() + $iCacheAge) . ' GMT');
+    header("Last-Modified: " . gmdate('D, d M Y H:i:s', @filemtime($sPath)) . ' GMT');
+    header("Accept-Ranges: bytes");
+    if ($sFilename)
+        header('Content-Disposition: ' . $sContentDisposition . '; filename="' . $sFilename . '"');
+
+    if (isset($_SERVER['HTTP_RANGE'])) {
+
+        $c_start = $start;
+        $c_end   = $end;
+
+        list(, $range) = explode('=', $_SERVER['HTTP_RANGE'], 2);
+        if (strpos($range, ',') !== false) {
+            header('HTTP/1.1 416 Requested Range Not Satisfiable');
+            header("Content-Range: bytes $start-$end/$size");
+            return false;
+        }
+        if ($range == '-') {
+            $c_start = $size - substr($range, 1);
+        }else{
+            $range  = explode('-', $range);
+            $c_start = $range[0];
+            $c_end   = (isset($range[1]) && is_numeric($range[1])) ? $range[1] : $size;
+        }
+        $c_end = ($c_end > $end) ? $end : $c_end;
+        if ($c_start > $c_end || $c_start > $size - 1 || $c_end >= $size) {
+            header('HTTP/1.1 416 Requested Range Not Satisfiable');
+            header("Content-Range: bytes $start-$end/$size");
+            return false;
+        }
+        $start  = $c_start;
+        $end    = $c_end;
+        $length = $end - $start + 1;
+        fseek($fp, $start);
+        header('HTTP/1.1 206 Partial Content');
+    }
+    header("Content-Range: bytes $start-$end/$size");
+    header("Content-Length: ".$length);
+
+    set_time_limit(0);
+    $buffer = 1024 * 8;
+    while(!feof($fp) && ($p = ftell($fp)) <= $end) {
+
+        if ($p + $buffer > $end) {
+            $buffer = $end - $p + 1;
+        }
+        echo fread($fp, $buffer);
+        flush();
+    }
+
+    fclose($fp);
+
+    return true;
+}
+
+/**
+ * Wrap in A tag links in TEXT string
+ * @param $text - text string without tags
+ * @param $sAttrs - attributes string to add to the added A tag
+ * @param $bHtmlSpecialChars - apply htmlspecialchars before processing
+ * @return string where all links are wrapped in A tag
+ */
+function bx_linkify($text, $sAttrs = '', $bHtmlSpecialChars = false)
+{
+    if ($bHtmlSpecialChars)
+        $text = htmlspecialchars($text, ENT_NOQUOTES, 'UTF-8');
+
+    $re = "@\b((https?://)|(www\.))(([0-9a-zA-Z_!~*'().&=+$%-]+:)?[0-9a-zA-Z_!~*'().&=+$%-]+\@)?(([0-9]{1,3}\.){3}[0-9]{1,3}|([0-9a-zA-Z_!~*'()-]+\.)*([0-9a-zA-Z][0-9a-zA-Z-]{0,61})?[0-9a-zA-Z]\.[a-zA-Z]{2,16})(:[0-9]{1,4})?((/[0-9a-zA-Z_!~*'().;?:\@&=+$,%#-]+)*/?)@";
+    preg_match_all($re, $text, $matches, PREG_OFFSET_CAPTURE);
+
+    $matches = $matches[0];
+
+    if ($i = count($matches))
+        $bAddNofollow = getParam('sys_add_nofollow') == 'on';
+
+    while ($i--)
+    {
+        $sAttrsLocal = $sAttrs;
+        $url = $matches[$i][0];
+        if (!preg_match('@^https?://@', $url))
+            $url = 'http://'.$url;
+
+        if (strncmp(BX_DOL_URL_ROOT, $url, strlen(BX_DOL_URL_ROOT)) !== 0) {
+            $sAttrsLocal .= ' target="_blank" ';
+            if ($bAddNofollow)
+                $sAttrsLocal .= ' rel="nofollow" ';
+        }
+
+        $text = substr_replace($text, '<a ' . $sAttrsLocal . ' href="'.$url.'">'.$matches[$i][0].'</a>', $matches[$i][1], strlen($matches[$i][0]));
+    }
+	
+	// email pattern
+	$mail_pattern = "/([A-z0-9\._-]+\@[A-z0-9_-]+\.)([A-z0-9\_\-\.]{1,}[A-z])/";
+	$text = preg_replace($mail_pattern, '<a href="mailto:$1$2">$1$2</a>', $text);
+	
+    return $text;
+}
+
+function bx_linkify_embeded($sText)
+{
+    $sRegex = '/\b((https?:\/\/)|(www\.))((([0-9a-zA-Z_!~*\'().&=+$%-]+:)?[0-9a-zA-Z_!~*\'().&=+$%-]+@)?(([0-9]{1,3}\.){3}[0-9]{1,3}|([0-9a-zA-Z_!~*\'()-]+\.)*([0-9a-zA-Z][0-9a-zA-Z-]{0,61})?[0-9a-zA-Z]\.[a-zA-Z]{2,16})(:[0-9]{1,4})?((\/[0-9a-zA-Z_!~*\'().;?:@&=+$,%#-]*)*))/';
+    $aMatches = [];
+    preg_match_all($sRegex, $sText, $aMatches, PREG_SET_ORDER);
+
+    // Reverse the matches to mimic JavaScript's reverse order processing
+    $aMatches = array_reverse($aMatches);
+
+    $sUrlRoot = BX_DOL_URL_ROOT;
+    $sUrlRootApi = bx_api_get_base_url();
+
+    $sLink = '';
+    foreach($aMatches as $aMatch) {
+        if(!strstr($aMatch[0], $sUrlRoot) && ($sUrlRootApi === false || !strstr($aMatch[0], $sUrlRootApi))) {
+            $sLink = $aMatch[0]; // Returns the first URL that doesn't include the restricted domains
+        }
+    }
+
+    $sResult = '';
+    if($sLink) {
+        bx_import('BxDolEmbed');
+        if(($oEmbed = BxDolEmbed::getObjectInstance('sys_system')) !== false)
+            $sResult = $oEmbed->getLinkHTML($sLink);
+    }
+
+    return $sResult;
+}
+
+/**
+ * Wrap in A tag links in HTML string, which aren't wrapped in A tag yet
+ * @param $sHtmlOrig - HTML string
+ * @param $sAttrs - attributes string to add to the added A tag
+ * @return modified HTML string, in case of errror original string is returned
+ */
+function bx_linkify_html($sHtmlOrig, $sAttrs = '') 
+{
+    if (!trim($sHtmlOrig))
+        return $sHtmlOrig;
+
+    $sId = 'bx-linkify-' . md5(microtime());
+    $dom = new DOMDocument();
+    @$dom->loadHTML('<?xml encoding="UTF-8"><div id="' . $sId . '">' . $sHtmlOrig . '</div>');
+    $xpath = new DOMXpath($dom);
+
+    foreach ($xpath->query('//text()') as $text) {
+        if (!empty($text->parentNode) && !empty($text->parentNode->tagName) && 'a' == $text->parentNode->tagName)
+            continue;
+        $frag = $dom->createDocumentFragment();
+        @$frag->appendXML(bx_linkify($text->nodeValue, $sAttrs, true));
+        $text->parentNode->replaceChild($frag, $text);
+    }
+
+    if (version_compare(PHP_VERSION, '5.3.6') >= 0)
+        $s = $dom->saveHTML($dom->getElementById($sId));
+    else
+        $s = $dom->saveXML($dom->getElementById($sId), LIBXML_NOEMPTYTAG);
+
+    if (false === $s) // in case of error return original string
+        return $sHtmlOrig;
+
+    if (false !== ($iPos = mb_strpos($s, '<html><body>')) && $iPos < mb_strpos($s, $sId))
+        $s = mb_substr($s, $iPos + 12, -15); // strip <html><body> tags and everything before them
+
+    return mb_substr($s, 54, -6); // strip added tags
+}
+
+/**
+ * Returns current site protocol http:// or https://
+ */
+function bx_proto()
+{
+    return 0 === strncmp('https', BX_DOL_URL_ROOT, 5) ? 'https' : 'http';
+}
+
+/**
+ * Checks protocol in the link
+ */
+function bx_has_proto($sLink)
+{
+    return preg_match('@^https?://@', $sLink);    
+}
+
+function bx_is_empty_array ($a)
+{
+    if (!is_array($a))
+        return true;
+    if (empty($a))
+        return true;
+    foreach ($a as $k => $v)
+        if ($v)
+            return false;
+    return true;
+}
+
+function bx_is_full_array ($a)
+{
+    if (!is_array($a))
+        return false;
+    if (empty($a))
+        return false;
+    foreach ($a as $k => $v)
+        if (empty($v))
+            return false;
+    return true;
+}
+
+function bx_is_url_in_content ($sContent, $bSkipLocalUrls = false)
+{
+    if ($bSkipLocalUrls)
+        $sContent = str_replace(BX_DOL_URL_ROOT, '', $sContent);
+
+    $a = array(
+        'http://',
+        'https://',
+        'https/',
+        'http/',
+        'www.',
+        '.com'
+    );
+    foreach ($a as $s)
+        if (false !== strpos($sContent, $s))
+            return true;
+
+    return false;
+}
+
+function bx_is_dynamic_request ()
+{
+    return bx_get('dynamic') || (isset($_SERVER['HTTP_X_REQUESTED_WITH']) && 'XMLHttpRequest' == $_SERVER['HTTP_X_REQUESTED_WITH']);
+}
+
+function bx_is_htmx_request ()
+{
+    return !empty($_SERVER['HTTP_HX_REQUEST']);
+}
+
+function bx_get_htmx_target ()
+{
+    if(!bx_is_htmx_request())
+        return false;
+
+    return isset($_SERVER['HTTP_HX_TARGET']) ? $_SERVER['HTTP_HX_TARGET'] : false;
+}
+
+function bx_get_htmx_attrs ($aAttrs, $mPreload = false)
+{
+    if(!$aAttrs)
+        return '';
+
+    if($mPreload === true)
+        $mPreload = 'mousedown';
+
+    $aHxAttrs = [];
+    array_walk($aAttrs, function($mixedValue, $sIndex) use (&$aHxAttrs) {
+        $aHxAttrs['hx-' . $sIndex] = $mixedValue;
+    });
+
+    return bx_convert_array2attrs($aHxAttrs) . ($mPreload ? ' preload="' . $mPreload . '"' : '');
+}
+
+function bx_idn_to_utf8($sUrl, $bReturnDomain = false)
+{
+    return bx_idn_to('idn_to_utf8', $sUrl, $bReturnDomain);
+}
+
+function bx_idn_to_ascii($sUrl, $bReturnDomain = false)
+{
+    return bx_idn_to('idn_to_ascii', $sUrl, $bReturnDomain);
+}
+
+function bx_idn_to($sMethod, $sUrl, $bReturnDomain = false)
+{
+    $aUrl = parse_url($sUrl);
+    if($aUrl === false)
+        return $sUrl;
+
+    $sResult = $aUrl['host'];
+    if(function_exists($sMethod))
+        $sResult = $sMethod($sResult, IDNA_DEFAULT, (defined('INTL_IDNA_VARIANT_UTS46') ? INTL_IDNA_VARIANT_UTS46 : INTL_IDNA_VARIANT_2003));
+
+    if($bReturnDomain)
+        return $sResult;
+
+    $sResult = (!empty($aUrl['scheme']) ? $aUrl['scheme'] . '://' : '' ) . $sResult;
+    $sResult = $sResult . (!empty($aUrl['path']) ? $aUrl['path'] : '');
+    $sResult = $sResult . (!empty($aUrl['query']) ? '?' . $aUrl['query'] : '');
+
+    return $sResult;
+}
+
+function bx_is_mobile()
+{
+    if(!isset($_SERVER['HTTP_USER_AGENT']))
+        return false;
+
+    $s = $_SERVER['HTTP_USER_AGENT'];
+    return preg_match('/(android|bb\d+|meego|una).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i', $s) || preg_match('/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i', substr($s, 0, 4));
+}
+
+function bx_get_device_pixel_ratio()
+{
+    $iRatio = 1;
+    if(!isset($_COOKIE['devicePixelRatio']))
+        return $iRatio;
+
+    $iDevicePixelRatio = intval($_COOKIE['devicePixelRatio']);
+    if($iDevicePixelRatio >= 2)
+        $iRatio = 2;
+
+    return $iRatio;
+}
+
+/**
+ * Log to logs object
+ * @param $sObject - logs object
+ * @param $mixed - string or array to log
+ */
+function bx_log($sObject, $mixed)
+{
+    if (class_exists('BxDolLogs', true) && $o = BxDolLogs::getObjectInstance($sObject))
+        return $o->add($mixed);
+    else
+        return false;
+}
+
+function bx_birthday2age($sBirthday)
+{
+    if(!$sBirthday)
+        return 0;
+
+    $iPosSpace = strpos($sBirthday, ' ');
+    if($iPosSpace !== false)
+        $sBirthday = trim(substr($sBirthday, 0, $iPosSpace));
+
+    $aDate = explode('-', $sBirthday);
+
+    $iCdYear = (int)date('Y');
+    $iCdMonth = (int)date('n');
+    $iCdDay = (int)date('j');
+
+    $iResult = 0;
+    if(isset($aDate[0]))
+        $iResult = $iCdYear - (int)$aDate[0];
+    if((isset($aDate[1]) && $iCdMonth < (int)$aDate[1]) || (isset($aDate[1], $aDate[2]) && $iCdMonth == (int)$aDate[1] && $iCdDay < (int)$aDate[2]))
+        $iResult -= 1;
+
+    return $iResult;
+}
+
+function bx_setcookie($sName, $sValue = "", $oExpiresOrOptions = 0, $sPath = 'auto', $sDomain = '', $bSecure = 'auto', $bHttpOnly = false)
+{
+       
+    $aUrl = 'auto' === $sPath || 'auto' === $bSecure ? parse_url(BX_DOL_URL_ROOT) : [];
+
+    if (defined('BX_MULTISITE_URL_COOKIE')) {
+        $aUrl = parse_url(BX_MULTISITE_URL_COOKIE);
+        $sDomain = $aUrl['host'];
+    }
+
+    if (bx_is_api())
+        $sPath = trim(getParam('sys_api_cookie_path'));
+    elseif ('auto' === $sPath)
+        $sPath = isset($aUrl['path']) && !empty($aUrl['path']) ? $aUrl['path'] : '/';
+
+    if (bx_is_api())
+        $bSecure = !!getParam('sys_api_cookie_secure');
+    elseif ('auto' === $bSecure)
+        $bSecure = 0 === strcasecmp('https', $aUrl['scheme']);
+
+    if (PHP_VERSION_ID < 70300) {
+        if (!defined('BX_MULTISITE_URL_COOKIE') && ('memberPassword' == $sName || 'memberSession' == $sName))
+            $sPath .= '; SameSite=' . (bx_is_api() ? getParam('sys_api_cookie_samesite') : getParam('sys_samesite_cookies'));
+        return setcookie($sName, $sValue, $oExpiresOrOptions, $sPath, $sDomain, $bSecure, $bHttpOnly);
+    } 
+    else {
+        $aOptions = is_array($oExpiresOrOptions) ? $oExpiresOrOptions : [
+            'expires' => $oExpiresOrOptions, 
+            'path' => $sPath, 
+            'domain' => $sDomain, 
+            'secure' => $bSecure, 
+            'httponly' => $bHttpOnly,
+        ];
+        if (!defined('BX_MULTISITE_URL_COOKIE') && !isset($aOptions['samesite']) && ('memberPassword' == $sName || 'memberSession' == $sName))
+            $aOptions['samesite'] = bx_is_api() ? getParam('sys_api_cookie_samesite') : getParam('sys_samesite_cookies');
+        return setcookie($sName, $sValue, $aOptions);
+    }
+}
+
+function bx_get_ip_hash($sIp)
+{
+    return sprintf("%u", crc32(crc32($sIp) + crc32(BX_DOL_SECRET)));
+}
+
+function is_private_ip ($sIp)
+{
+    if (filter_var($sIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6) !== $sIp)
+        return false;
+
+    return filter_var($sIp, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_IPV6 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== $sIp;
+}
+
+function bx_absolute_url($sUrl, $sPrefix = BX_DOL_URL_ROOT)
+{
+    if (!preg_match('/^https?:\/\//', $sUrl))
+        $sUrl = $sPrefix . $sUrl;
+    return $sUrl;
+}
+
+function bx_is_api()
+{
+    return defined('BX_API') || bx_get('api') ? true : false;
+}
+
+function bx_api_get_sql($q)
+{
+    $sql = false;
+    $t = false;
+    if ($q == 'accounts_count'){
+        $sql = 'SELECT Count(*) FROM sys_accounts';
+        $t = 'One';
+    }
+    
+    if ($sql){
+        $oDb = BxDolDb::getInstance();
+        if ($t == 'One')
+            echo json_encode($oDb->getOne($sql));
+        else
+            echo json_encode($oDb->getAll($sql));
+        exit;
+    }
+}
+
+function bx_api_check_access()
+{
+    if (!getParam('sys_api_enable')) {
+        header('HTTP/1.0 403 Forbidden');
+        BxDolLanguages::getInstance();
+        echo json_encode(['status' => 403, 'error' => _t("_Access denied")]);
+        exit;
+    }
+
+    $aHeaders = function_exists('getallheaders') ? getallheaders() : false;
+    if ($aHeaders) {
+        $sOriginHeader = isset($aHeaders['Origin']) ? $aHeaders['Origin'] : (isset($aHeaders['origin']) ? $aHeaders['origin'] : false);
+        $sAuthHeader = isset($aHeaders['Authorization']) ? $aHeaders['Authorization'] : (isset($aHeaders['authorization']) ? $aHeaders['authorization'] : false);
+    }
+    else {
+        $sOriginHeader = isset($_SERVER['HTTP_ORIGIN']) ? $_SERVER['HTTP_ORIGIN'] : false;
+        $sAuthHeader = isset($_SERVER['HTTP_AUTHORIZATION']) ? $_SERVER['HTTP_AUTHORIZATION'] : false;
+    }
+
+    if ($sAuthHeader && getParam('sys_api_access_by_key')) {
+        if (!BxDolApiQuery::getInstance()->getKey(str_replace('Bearer ', '', $sAuthHeader))) {
+            header('HTTP/1.0 403 Forbidden');
+            BxDolLanguages::getInstance();
+            echo json_encode(['status' => 403, 'error' => _t("_Access denied")]);
+            exit;
+        }
+        if(bx_get('r') == 'q' && getParam('sys_api_access_unsafe_services')){
+            bx_api_get_sql(bx_get('q'));
+        }
+    }
+    elseif (getParam('sys_api_access_by_origin') && $sOriginHeader) {
+
+        if (parse_url($sOriginHeader, PHP_URL_HOST) != parse_url(BX_DOL_URL_ROOT, PHP_URL_HOST)) {
+
+            if (!BxDolApiQuery::getInstance()->getOrigin($sOriginHeader)) {
+                header('HTTP/1.0 403 Forbidden');
+                BxDolLanguages::getInstance();
+                echo json_encode(['status' => 403, 'error' => _t("_Access denied")]);
+                exit;
+            }
+
+            header('Access-Control-Allow-Origin: ' . $sOriginHeader);
+            header('Access-Control-Allow-Credentials: true');
+            
+            // with acces by origin this block has to be disabled!!!
+            // if(bx_get('r') == 'q'){
+            //    bx_api_get_sql(bx_get('q'));
+            // }
+
+            if ('OPTIONS' == $_SERVER['REQUEST_METHOD']) {
+                header('Access-Control-Allow-Methods: POST, GET');
+                header('Access-Control-Allow-Headers: Accept-Encoding, Authorization, Cache-Control, Connection, Host, Origin, Pragma, Referer, User-Agent, X-Custom-Header, X-Requested-With');
+                exit;
+            }
+        }
+    }
+    else {
+        header('HTTP/1.0 403 Forbidden');
+        BxDolLanguages::getInstance();
+        echo json_encode(['status' => 403, 'error' => _t("_Access denied")]);
+        exit;
+    }
+
+    // TODO: Temporarily to use logged state
+    // bx_login(1);
+    // check_logged();
+}
+
+function bx_api_get_base_url()
+{
+    if(($sResult = getParam('sys_api_url_root_email')) != '')
+        return $sResult;
+
+    if(($sResult = getParam('sys_api_url_root_push')) != '')
+        return $sResult;
+
+    return false;
+}
+    
+function bx_api_get_relative_url($sUrl, $sPrefix = BX_DOL_URL_ROOT)
+{
+    $a = '/' . bx_ltrim_str($sUrl, $sPrefix);
+    if ($a == '/studio/')
+        return BX_DOL_URL_ROOT . 'studio/';
+    return $a;
+}
+
+function bx_api_get_block($sType, $sData, $aParams = [])
+{   $aData = ['id' => isset($aParams['id']) ? $aParams['id'] : 1 , 'type' => $sType, 'data' => $sData];
+    if (isset($aParams['ext']))
+        $aData = array_merge($aData, $aParams['ext']);
+    return $aData;
+}
+
+function bx_api_get_msg($sMessage, $aParams = [])
+{
+    return bx_api_get_block('msg', $sMessage != '' ? _t($sMessage) : '', $aParams);
+}
+
+function bx_api_get_image($mixedStorage, $iId)
+{
+    $sStorage = $sTranscoder = '';
+
+    if(is_array($mixedStorage))
+        list($sStorage, $sTranscoder) = $mixedStorage;
+    else
+        $sStorage = $mixedStorage;
+
+    $sUrl = '';
+    $iWidth = 500;
+    $iHeight = 500;
+    if($sTranscoder && ($oTranscoder = BxDolTranscoder::getObjectInstance($sTranscoder))) {
+        $sUrl = $oTranscoder->getFileUrl($iId);
+
+        if(($aSize = $oTranscoder->getSize($sUrl)) !== false) {
+            $iWidth = (int)$aSize['w'];
+            $iHeight = (int)$aSize['h'];
+        }
+    }
+    
+    if(!$sUrl && ($oS = BxDolStorage::getObjectInstance($sStorage))) {
+        $sUrl = $oS->getFileUrlById($iId);
+
+        if(($aFile = $oS->getFile($iId)) && !empty($aFile['dimensions'])) {
+            $aTmp = explode('x', $aFile['dimensions']);
+            $iWidth = (int)$aTmp[0];
+            $iHeight = (int)$aTmp[1];
+        }
+    }
+
+    return !empty($sUrl) ? [
+        'storage' => $sStorage,
+        'src' => $sUrl,
+        'width' => $iWidth,
+        'height' => $iHeight,
+    ] : false;
+}
+
+function bx_api_get_browse_params($sParams, $bParamsOnly = false)
+{
+    if(!$sParams || !is_string($sParams))
+        return [];
+
+    $aParams = json_decode($sParams, true);
+    if(isset($aParams['params']['validate']) && !is_array($aParams['params']['validate']))
+        $aParams['params']['validate'] = !empty($aParams['params']['validate']) ? explode(',', $aParams['params']['validate']) : [];
+
+    if(!$bParamsOnly)
+        return $aParams;
+
+    if(!isset($aParams['params']))
+        return [];
+
+    return $aParams['params'];
+}
+
+function bx_is_serialized_safe($str) 
+{
+    if (!is_string($str))
+        return false;
+    
+    $str = trim($str);
+    if ($str === 'N;')
+        return true; // null
+    
+    if (!preg_match('/^([adObis]):[0-9]+:/', $str, $matches))
+        return false;
+    
+    $type = $matches[1];
+    
+    // allow only base types, exclude objects (O) are references (r, R)
+    $allowedTypes = ['a', 's', 'i', 'd', 'b'];
+    if (!in_array($type, $allowedTypes))
+        return false;
+    
+    try {
+        $unserialized = unserialize($str);
+        return true;
+    } catch (Exception $e) {
+        return false;
+    }
+}
+
+
+function bx_is_serialized($string) 
+{
+    if (!is_string($string))
+        return false;
+    
+    $string = trim($string);
+
+    // base types    
+    if ($string === 'N;') return true; // null
+    if ($string === 'b:0;' || $string === 'b:1;') return true; // boolean
+    
+    // other types
+    return preg_match('/^([adObis]):[0-9]+:/', $string) === 1;
+}
+
+/** @} */

+ 12 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/inc/version.inc.php

@@ -0,0 +1,12 @@
+<?php defined('BX_DOL') or defined('BX_DOL_INSTALL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+define('BX_DOL_VERSION', '14.0.0-RC5'); ///< system files version, for DB version @see bx_get_ver, DB and files version must match
+
+/** @} */

+ 874 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/modules/base/general/classes/BxBaseModGeneralFormsEntryHelper.php

@@ -0,0 +1,874 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    BaseGeneral Base classes for modules
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Entry forms helper functions
+ */
+class BxBaseModGeneralFormsEntryHelper extends BxDolProfileForms
+{
+    protected $_oModule;
+
+    protected $_bIsApi;
+    protected $_bDynamicMode;
+
+    /**
+     * 'Ajax Mode' determines the format of response. If it's TRUE the response 
+     * (a form or an error appeared during form creation) should be returned 
+     * as text during initial loading, while all other responses appeared after 
+     * form submit should be arrays, which are ready to path to echoJson.
+     */
+    protected $_bAjaxMode;
+
+    /**
+     * Use absolute Action URL in generated form object. 
+     * It's needed in Ajax Mode.
+     */
+    protected $_bAbsoluteActionUrl;
+
+    protected $_mixedContextId;    
+
+    public function __construct($oModule)
+    {
+        parent::__construct();
+        $this->_oModule = $oModule;
+
+        $this->_bIsApi = bx_is_api();
+
+        $this->_bDynamicMode = false;
+
+        $this->_bAjaxMode = false;
+        $mixedAjaxMode = bx_get('ajax_mode');
+        if($mixedAjaxMode !== false)
+            $this->setAjaxMode($mixedAjaxMode);
+
+        $this->_bAbsoluteActionUrl = false;
+        $mixedAbsoluteActionUrl = bx_get('absolute_action_url');
+        if($mixedAbsoluteActionUrl !== false)
+            $this->setAbsoluteActionUrl($mixedAbsoluteActionUrl);
+
+        $this->_mixedContextId = false;
+    }
+
+    public function setAjaxMode($bAjaxMode)
+    {
+        $this->_bAjaxMode = (bool)$bAjaxMode;
+        if($this->_bAjaxMode)
+            $this->setDynamicMode(true);
+    }
+
+    public function setAbsoluteActionUrl($bAbsoluteActionUrl)
+    {
+        $this->_bAbsoluteActionUrl = (bool)$bAbsoluteActionUrl;
+    }
+
+    public function setContextId($mixedContextId)
+    {
+        $this->_mixedContextId = is_numeric($mixedContextId) ? (int)$mixedContextId : (bool)$mixedContextId;
+    }
+
+    public function setDynamicMode($bDynamicMode)
+    {
+        $this->_bDynamicMode = (bool)$bDynamicMode;
+    }
+
+    public function getObjectStorage()
+    {
+        return BxDolStorage::getObjectInstance($this->_oModule->_oConfig->CNF['OBJECT_STORAGE']);
+    }
+
+    public function getObjectFormAdd ($sDisplay = false)
+    {
+    	$CNF = &$this->_oModule->_oConfig->CNF;
+
+        if(false === $sDisplay)
+            $sDisplay = $CNF['OBJECT_FORM_ENTRY_DISPLAY_ADD'];
+
+        $oForm = BxDolForm::getObjectInstance($CNF['OBJECT_FORM_ENTRY'], $sDisplay, $this->_oModule->_oTemplate);
+        if($this->_bAjaxMode)
+            $oForm->setAjaxMode($this->_bAjaxMode);
+
+        if($this->_bAbsoluteActionUrl)
+            $this->_setAbsoluteActionUrl('add', $oForm);
+
+        return $oForm; 
+    }
+
+    public function getObjectFormEdit ($sDisplay = false)
+    {
+    	$CNF = &$this->_oModule->_oConfig->CNF;
+
+        if(false === $sDisplay)
+            $sDisplay = $CNF['OBJECT_FORM_ENTRY_DISPLAY_EDIT'];
+
+        $oForm = BxDolForm::getObjectInstance($CNF['OBJECT_FORM_ENTRY'], $sDisplay, $this->_oModule->_oTemplate);
+        if($this->_bAjaxMode)
+            $oForm->setAjaxMode($this->_bAjaxMode);
+
+        if($this->_bAbsoluteActionUrl)
+            $this->_setAbsoluteActionUrl('edit', $oForm);
+
+        return $oForm;
+    }
+
+    public function getObjectFormView ($sDisplay = false)
+    {
+    	$CNF = &$this->_oModule->_oConfig->CNF;
+
+        if (false === $sDisplay)
+            $sDisplay = $CNF['OBJECT_FORM_ENTRY_DISPLAY_VIEW'];
+
+        return BxDolForm::getObjectInstance($CNF['OBJECT_FORM_ENTRY'], $sDisplay, $this->_oModule->_oTemplate);
+    }
+
+    public function getObjectFormDelete ($sDisplay = false)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        if (false === $sDisplay)
+            $sDisplay = $CNF['OBJECT_FORM_ENTRY_DISPLAY_DELETE'];
+
+        $oForm = BxDolForm::getObjectInstance($CNF['OBJECT_FORM_ENTRY'], $sDisplay, $this->_oModule->_oTemplate);
+        if($this->_bAjaxMode)
+            $oForm->setAjaxMode($this->_bAjaxMode);
+
+        if($this->_bAbsoluteActionUrl)
+            $this->_setAbsoluteActionUrl('delete', $oForm);
+
+        return $oForm;
+    }
+
+    public function viewDataEntry ($iContentId)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $sMsgCnt = '';
+        // get content data and profile info
+        list ($oProfile, $aContentInfo) = $this->_getProfileAndContentData($iContentId);
+        if (!$aContentInfo)
+            $sMsgCnt = _t('_sys_txt_error_entry_is_not_defined');
+
+        // check access
+        if (empty($sMsgCnt) && CHECK_ACTION_RESULT_ALLOWED !== ($sMsg = $this->_oModule->checkAllowedView($aContentInfo)))
+            $sMsgCnt = $sMsg;
+        
+        if ($sMsgCnt)
+            return bx_is_api() ? [bx_api_get_msg($sMsgCnt)] : MsgBox($sMsgCnt);
+
+        $oForm = $this->getObjectFormView();
+        if (!$oForm)
+            return '';
+
+        $oForm->initChecker($aContentInfo);
+
+        if(!empty($CNF['FIELD_TEXT']) &&  !$oForm->isInputVisible($CNF['FIELD_TEXT']))
+            $s = '';
+        else
+            $s = $this->_oModule->_oTemplate->entryText($aContentInfo);
+        
+        return bx_is_api() ? [bx_api_get_block('entity_text', $s)] : $s;
+    }
+
+    public function addData ($iProfile, $aValues, $sDisplay = false)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        // check and display form
+        $oForm = $this->getObjectFormAdd($sDisplay);
+        if (!$oForm)
+            return array('code' => 1, 'message' => '_sys_txt_error_occured');
+
+        $oForm->aFormAttrs['method'] = BX_DOL_FORM_METHOD_SPECIFIC;
+        $oForm->aParams['csrf']['disable'] = true;
+        if(!empty($oForm->aParams['db']['submit_name'])) {            
+            $sSubmitName = false;
+            if (is_array($oForm->aParams['db']['submit_name'])) {
+                foreach ($oForm->aParams['db']['submit_name'] as $sVal) {
+                    if (isset($oForm->aInputs[$sVal])) {
+                        $sSubmitName = $sVal;
+                        break;
+                    }
+                }
+            } 
+            else {
+                $sSubmitName = $oForm->aParams['db']['submit_name'];
+            }
+            if ($sSubmitName && isset($oForm->aInputs[$sSubmitName]))
+                $aValues[$sSubmitName] = $oForm->aInputs[$sSubmitName]['value'];
+        }
+
+        $oForm->initChecker(array(), $aValues);
+        if (!$oForm->isSubmittedAndValid()) {
+            $aErrors = array();
+            array_walk($oForm->aInputs, function($aInput, $sKey) use (&$aErrors) {
+                if(!empty($aInput['error']))
+                    $aErrors[$sKey] = $aInput['error'];
+            });
+
+            return array('code' => 2, 'message' => '_sys_txt_error_occured', 'errors' => $aErrors);
+        }
+
+        // insert data into database
+        $aValsToAdd = array ();
+        if(isset($CNF['FIELD_AUTHOR']))
+            $aValsToAdd[$CNF['FIELD_AUTHOR']] = $iProfile;
+
+        $iContentId = $oForm->insert($aValsToAdd);
+        if (!$iContentId) {
+            if (!$oForm->isValid())
+                return array('code' => 2, 'message' => '_sys_txt_error_occured');
+            else
+                return array('code' => 3, 'message' => '_sys_txt_error_entry_creation');
+        }
+
+        $sResult = $this->onDataAddAfter(BxDolProfile::getInstance($iProfile)->getAccountId(), $iContentId);
+        if($sResult)
+            return array('code' => 4, 'message' => $sResult);
+
+        list($oProfile, $aContentInfo) = $this->_getProfileAndContentData($iContentId);
+
+        /*
+         * Process metas.
+         * Note. It's essential to process metas a the very end, 
+         * because all data related to an entry should be already
+         * processed and are ready to be passed to alert. 
+         */
+        $this->_oModule->processMetasAdd($iContentId);
+
+        /*
+         * Create alert about the completed action.
+         */
+        $this->_oModule->alertAfterAdd($aContentInfo);
+
+        return array('code' => 0, 'message' => '', 'content' => $aContentInfo);
+    }
+
+    public function addDataForm ($sDisplay = false, $sCheckFunction = false)
+    {
+        if (!$sCheckFunction)
+            $sCheckFunction = 'checkAllowedAdd';
+
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $bAsJson = false;
+
+        // get form object
+        $oForm = $this->getObjectFormAdd($sDisplay);
+        if (!$oForm)
+            return ($sMsg = '_sys_txt_error_occured') && $this->_bIsApi ? $this->prepareResponseAPI('msg', $sMsg) : $this->prepareResponse(MsgBox(_t($sMsg)), $bAsJson, 'msg');
+
+        $bAsJson = $this->_bAjaxMode && $oForm->isSubmitted();
+        // check access
+        if (CHECK_ACTION_RESULT_ALLOWED !== ($sMsg = $this->_oModule->$sCheckFunction())) {
+            if (($oProfile = BxDolProfile::getInstance()) !== false && ($aProfileInfo = $oProfile->getInfo()) && $aProfileInfo['type'] == 'system' && is_subclass_of($this->_oModule, 'BxBaseModProfileModule') && $this->_oModule->serviceActAsProfile()) // special check for system profile is needed, because of incorrect error message
+                return ($sMsg = '_sys_txt_access_denied') && $this->_bIsApi ? $this->prepareResponseAPI('msg', $sMsg) : $this->prepareResponse(MsgBox(_t($sMsg)), $bAsJson, 'msg');
+            else
+                return $this->_bIsApi ? $this->prepareResponseAPI('msg', $sMsg) : $this->prepareResponse(MsgBox($sMsg), $bAsJson, 'msg');
+        }
+
+        // check and display form
+        $oForm->initChecker();
+        if (!$oForm->isSubmittedAndValid()) {
+            if(!$oForm->isSubmitted() && isset($CNF['FIELD_ALLOW_VIEW_TO']) && ($oProfile = BxDolProfile::getInstance()) !== false && ($oPrivacy = BxDolPrivacy::getObjectInstance($CNF['OBJECT_PRIVACY_VIEW'])) !== false)
+                $oPrivacy->deleteGroupCustomByProfileIdUnused($oProfile->id());
+
+            return $this->_bIsApi ? $this->prepareResponseAPI('form', $oForm->getCodeAPI(), ['request_uri' => 'entity_create']) : $this->prepareResponse($oForm->getCode($this->_bDynamicMode), $bAsJson, 'form', [
+            	'form_id' => $oForm->getId()
+            ]);
+        }
+
+        // insert data into database
+        $aValsToAdd = array ();
+        $iContentId = $oForm->insert ($aValsToAdd);
+        if (!$iContentId) {
+            if (!$oForm->isValid())
+                return $this->_bIsApi ? $this->prepareResponseAPI('form', $oForm->getCodeAPI(), ['request_uri' => 'entity_create']) : $this->prepareResponse($oForm->getCode($this->_bDynamicMode), $bAsJson, 'form', [
+                    'form_id' => $oForm->getId()
+                ]);
+            else
+                return ($sMsg = '_sys_txt_error_entry_creation') && $this->_bIsApi ? $this->prepareResponseAPI('msg', $sMsg) : $this->prepareResponse(MsgBox(_t($sMsg)), $bAsJson, 'msg');
+        }
+
+        $sResult = $this->onDataAddAfter (getLoggedId(), $iContentId);
+        if ($sResult)
+            return $this->_bIsApi ? $this->prepareResponseAPI('msg', $sResult) : $this->prepareResponse($sResult, $bAsJson, 'msg');
+
+        list($oProfile, $aContentInfo) = $this->_getProfileAndContentData($iContentId);
+
+        /*
+         * Process metas.
+         * Note. It's essential to process metas a the very end, 
+         * because all data related to an entry should be already
+         * processed and are ready to be passed to alert. 
+         */
+        $this->_oModule->processMetasAdd($iContentId);
+
+        // Perform ACL action
+        $this->_oModule->$sCheckFunction(true);
+
+        // Create alert about the completed action.
+        $this->_oModule->alertAfterAdd($aContentInfo);
+
+        // Redirect
+        if ($this->_bIsApi)
+            return $this->redirectAfterAdd($aContentInfo);
+        else
+            $this->redirectAfterAdd($aContentInfo);
+    }
+            
+    public function redirectAfterAdd($aContentInfo, $sUrl = '')
+    {
+    	$CNF = &$this->_oModule->_oConfig->CNF;
+
+        if ($sUrl == '')
+            $sUrl = 'page.php?i=' . $CNF['URI_VIEW_ENTRY'] . '&id=' . $aContentInfo[$CNF['FIELD_ID']];
+
+        if(($mixedUrl = $this->_getRedirectFromContext('add', $aContentInfo)) !== false)
+              $sUrl = $mixedUrl;
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_general-redirect_after_add '{module_name}', 'redirect_after_add' - hook to override redirect URL which is used after content creation
+         * - $unit_name - module name
+         * - $action - equals `redirect_after_add`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `ajax_mode` - [boolean] dynamic loading is used or not
+         *      - `content` - [array] content info array as key&value pairs
+         *      - `override_result` - [string] by ref, redirect URL, can be overridden in hook processing
+         * @hook @ref hook-bx_base_general-redirect_after_add
+         */
+        bx_alert($this->_oModule->getName(), 'redirect_after_add', 0, false, [
+            'ajax_mode' => $this->_bAjaxMode,
+            'content' => $aContentInfo,
+            'override_result' => &$sUrl,
+        ]);
+
+        if($this->_bIsApi)
+            return !empty($sUrl) ? [bx_api_get_block('redirect', ['uri' => bx_api_get_relative_url(BxDolPermalinks::getInstance()->permalink($sUrl)), 'timeout' => 1000])] : [];
+
+        if($this->_bAjaxMode) {
+            echoJson($this->prepareResponse($sUrl, $this->_bAjaxMode, 'redirect'));
+            exit;
+        }
+        else
+            $this->_redirectAndExit($sUrl);
+    }
+
+    public function editDataForm ($iContentId, $sDisplay = false, $sCheckFunction = false, $bErrorMsg = true)
+    {
+        if (!$sCheckFunction)
+            $sCheckFunction = 'checkAllowedEdit';
+
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        // get content data and profile info
+        list ($oProfile, $aContentInfo) = $this->_getProfileAndContentData($iContentId);
+        if (!$aContentInfo)
+            return $bErrorMsg && ($sMsg = '_sys_txt_error_entry_is_not_defined') ? ($this->_bIsApi ? $this->prepareResponseAPI('msg', $sMsg) : MsgBox(_t($sMsg))) : '';
+
+        // check access
+        if (CHECK_ACTION_RESULT_ALLOWED !== ($sMsg = $this->_oModule->$sCheckFunction($aContentInfo)))
+            return $bErrorMsg ? ($this->_bIsApi ? $this->prepareResponseAPI('msg', $sMsg) : MsgBox($sMsg)) : '';
+
+        // check and display form
+        $oForm = $this->getObjectFormEdit($sDisplay);
+        if (!$oForm)
+            return $bErrorMsg && ($sMsg = '_sys_txt_error_occured') ? ($this->_bIsApi ? $this->prepareResponseAPI('msg', $sMsg) : MsgBox(_t($sMsg))) : '';
+
+        $aSpecificValues = array();        
+        if (!empty($CNF['OBJECT_METATAGS'])) {
+            $oMetatags = BxDolMetatags::getObjectInstance($CNF['OBJECT_METATAGS']);
+            if ($oMetatags->locationsIsEnabled())
+                $aSpecificValues = $oMetatags->locationGet($iContentId, empty($CNF['FIELD_LOCATION_PREFIX']) ? '' : $CNF['FIELD_LOCATION_PREFIX']);
+        }
+
+        $oForm->initChecker($aContentInfo, $aSpecificValues);
+        if (!$oForm->isSubmittedAndValid())
+            return $this->_bIsApi ? $this->prepareResponseAPI('form', $oForm->getCodeAPI(), ['request_uri' => 'entity_edit', 'request_params' => ['params' => [$iContentId, $oForm->aParams['display']]]]) : $oForm->getCode();
+
+        // update data in the DB
+        $aTrackTextFieldsChanges = null;
+
+        $this->onDataEditBefore ($aContentInfo[$CNF['FIELD_ID']], $aContentInfo, $aTrackTextFieldsChanges, $oProfile, $oForm);
+
+        if (!$oForm->update ($aContentInfo[$CNF['FIELD_ID']], array(), $aTrackTextFieldsChanges)) {
+            if (!$oForm->isValid())
+                return $this->_bIsApi ? $this->prepareResponseAPI('form', $oForm->getCodeAPI(), ['request_uri' => 'entity_edit', 'request_params' => ['params' => [$iContentId, $oForm->aParams['display']]]]) : $oForm->getCode();
+            else
+                return ($sMsg = '_sys_txt_error_entry_update') && $this->_bIsApi ? $this->prepareResponseAPI('msg', $sMsg) : MsgBox(_t($sMsg));
+        }
+
+        list ($oProfile, $aContentInfo) = $this->_getProfileAndContentData($iContentId);
+        $sResult = $this->onDataEditAfter ($aContentInfo[$CNF['FIELD_ID']], $aContentInfo, $aTrackTextFieldsChanges, $oProfile, $oForm);
+        if ($sResult)
+            return $this->_bIsApi ? $this->prepareResponseAPI('msg', $sResult) : $sResult;
+
+        /*
+         * Process metas.
+         * Note. It's essential to process metas a the very end, 
+         * because all data related to an entry should be already
+         * processed and are ready to be passed to alert. 
+         */
+        $this->_oModule->processMetasEdit($iContentId, $oForm);
+
+        // Perform ACL action
+        $this->_oModule->$sCheckFunction($aContentInfo, true);
+
+        // Create alert about the completed action.
+        $this->_oModule->alertAfterEdit($aContentInfo);
+
+        // Redirect
+        if ($this->_bIsApi)
+            return $this->redirectAfterEdit($aContentInfo);
+        else
+            $this->redirectAfterEdit($aContentInfo);
+    }
+
+    /**
+     * @return empty string on success or error message string on error
+     */
+    public function editData ($iContentId, $aValues, $sDisplay = false)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        // get content data and profile info
+        list ($oProfile, $aContentInfo) = $this->_getProfileAndContentData($iContentId);
+        if (!$aContentInfo)
+            return _t('_sys_txt_error_entry_is_not_defined');
+
+        // get display form
+        $oForm = $this->getObjectFormEdit($sDisplay);
+        if (!$oForm)
+            return _t('_sys_txt_error_occured');
+
+        foreach ($aValues as $k => $v) {
+            if (!isset($oForm->aInputs[$k]))
+                return _t('_sys_txt_forms_unknown_field_err', $k);
+        }
+
+        $oForm->aFormAttrs['method'] = BX_DOL_FORM_METHOD_SPECIFIC;
+        $oForm->aParams['csrf']['disable'] = true;
+        $oForm->initChecker($aContentInfo, $aContentInfo);
+
+        // before edit hook
+        $aTrackTextFieldsChanges = null;
+        $this->onDataEditBefore ($aContentInfo[$CNF['FIELD_ID']], $aContentInfo, $aTrackTextFieldsChanges, $oProfile, $oForm);
+
+        // update data in the DB
+        if (!$oForm->update ($aContentInfo[$CNF['FIELD_ID']], $aValues, $aTrackTextFieldsChanges)) {
+            return _t('_sys_txt_error_entry_update');
+        }
+
+        // after edit hook
+        list ($oProfile, $aContentInfo) = $this->_getProfileAndContentData($iContentId);
+        $sResult = $this->onDataEditAfter ($aContentInfo[$CNF['FIELD_ID']], $aContentInfo, $aTrackTextFieldsChanges, $oProfile, $oForm);
+        if ($sResult)
+            return $sResult;
+
+        /*
+         * Process metas.
+         * Note. It's essential to process metas a the very end, 
+         * because all data related to an entry should be already
+         * processed and are ready to be passed to alert. 
+         */
+        $this->_oModule->processMetasEdit($iContentId, $oForm);
+
+        // Create alert about the completed action.
+        $this->_oModule->alertAfterEdit($aContentInfo);
+
+        return '';
+    }
+
+    protected function redirectAfterEdit($aContentInfo, $sUrl = '')
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        if ($sUrl == '')
+            $sUrl = 'page.php?i=' . $CNF['URI_VIEW_ENTRY'] . '&id=' . $aContentInfo[$CNF['FIELD_ID']];
+
+        if(($mixedUrl = $this->_getRedirectFromContext('edit', $aContentInfo)) !== false)
+            $sUrl = $mixedUrl;
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_general-redirect_after_edit '{module_name}', 'redirect_after_edit' - hook to override redirect URL which is used after content changing
+         * It's equivalent to @ref hook-bx_base_general-redirect_after_add
+         * except `ajax_mode` parameter in $extra_params is missing
+         * @hook @ref hook-bx_base_general-redirect_after_edit
+         */
+        bx_alert($this->_oModule->getName(), 'redirect_after_edit', 0, false, [
+            'content' => $aContentInfo,
+            'override_result' => &$sUrl,
+        ]);
+
+        if($this->_bIsApi)
+            return !empty($sUrl) ? bx_api_get_block('redirect', ['uri' => bx_api_get_relative_url(BxDolPermalinks::getInstance()->permalink($sUrl)), 'timeout' => 1000]) : [];
+
+        $this->_redirectAndExit($sUrl);
+    }
+
+    public function deleteDataForm ($iContentId, $sDisplay = false, $sCheckFunction = false)
+    {
+        if (!$sCheckFunction)
+            $sCheckFunction = 'checkAllowedDelete';
+        
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        // get content data and profile info
+        list ($oProfile, $aContentInfo) = $this->_getProfileAndContentData($iContentId);
+        if (!$aContentInfo)
+            return ($sMsg = '_sys_txt_error_entry_is_not_defined') && $this->_bIsApi ? bx_api_get_msg($sMsg) : MsgBox(_t($sMsg));
+
+        // check access
+        if (CHECK_ACTION_RESULT_ALLOWED !== ($sMsg = $this->_oModule->$sCheckFunction($aContentInfo)))
+            return $this->_bIsApi ? bx_api_get_msg($sMsg) : MsgBox($sMsg);
+
+        // check and display form
+        $oForm = $this->getObjectFormDelete($sDisplay);
+        if (!$oForm)
+            return ($sMsg = '_sys_txt_error_occured') && $this->_bIsApi ? bx_api_get_msg($sMsg) : MsgBox(_t($sMsg));
+
+        $oForm->initChecker($aContentInfo);
+        if (!$oForm->isSubmittedAndValid())
+            return $this->_bIsApi ? $oForm : $oForm->getCode();
+
+        if ($sError = $this->deleteData($aContentInfo[$CNF['FIELD_ID']], $aContentInfo, $oProfile, $oForm))
+            return $this->_bIsApi ? bx_api_get_msg($sError) : MsgBox($sError);
+
+        // perform action
+        $this->_oModule->$sCheckFunction($aContentInfo, true);
+
+        // redirect
+        if (bx_is_api())
+            return $this->redirectAfterDelete($aContentInfo);
+        else
+            $this->redirectAfterDelete($aContentInfo);
+    }
+
+    protected function redirectAfterDelete($aContentInfo)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $sUrl = $CNF['URL_HOME'];
+        $aMarkers = array(
+            'account_id' => getLoggedId(),
+            'profile_id' => bx_get_logged_profile_id(),
+        );
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_general-redirect_after_delete '{module_name}', 'redirect_after_delete' - hook to override redirect URL which is used after content deletion
+         * It's equivalent to @ref hook-bx_base_general-redirect_after_add
+         * except `markers` parameter was added to $extra_params. It allows to override an array of markers, which can be parsed in URL.
+         * @hook @ref hook-bx_base_general-redirect_after_delete
+         */
+        bx_alert($this->_oModule->getName(), 'redirect_after_delete', 0, false, [
+            'content' => $aContentInfo,
+            'markers' => &$aMarkers,
+            'override_result' => &$sUrl,
+        ]);
+
+        if($this->_bIsApi)
+            return bx_api_get_block('redirect', ['uri' => bx_api_get_relative_url(BxDolPermalinks::getInstance()->permalink($sUrl)), 'timeout' => 1000]);
+
+        $this->_redirectAndExit($sUrl, true, $aMarkers);
+    }
+
+    /**
+     * Delete data entry
+     * @param $iContentId entry id
+     * @param $oForm optional content info array
+     * @param $aContentInfo optional content info array
+     * @param $oProfile optional content author profile
+     * @return error string on error or empty string on success
+     */
+    public function deleteData ($iContentId, $aContentInfo = false, $oProfile = null, $oForm = null)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        if (!$aContentInfo || !$oProfile)
+            list ($oProfile, $aContentInfo) = $this->_getProfileAndContentData($iContentId);
+
+        if (!$aContentInfo)
+            return _t('_sys_txt_error_entry_is_not_defined');
+
+        if (!$oForm)
+            $oForm = $this->getObjectFormDelete();
+
+        if (!$oForm->delete ($aContentInfo[$CNF['FIELD_ID']], $aContentInfo))
+            return _t('_sys_txt_error_entry_delete');
+
+        // remove data from nested forms 
+        $aNestedForms = BxDolFormQuery::getNestedFormObjects($oForm->aParams['object']);
+        foreach ($aNestedForms as $aNestedForm) {
+            BxDolFormQuery::deleteDataFromNestedForm($aNestedForm['table'], $iContentId);
+        }
+        
+        if ($sResult = $this->onDataDeleteAfter ($aContentInfo[$CNF['FIELD_ID']], $aContentInfo, $oProfile))
+            return $sResult;
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_general-deleted '{module_name}', 'deleted' - hook after content was deleted
+         * - $unit_name - module name
+         * - $action - equals `deleted`
+         * - $object_id - content id
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `content` - [array] by ref, content info array as key&value pairs, can be overridden in hook processing
+         * @hook @ref hook-bx_base_general-deleted
+         */
+        bx_alert($this->_oModule->getName(), 'deleted', $aContentInfo[$CNF['FIELD_ID']], false, [
+            'content' => &$aContentInfo
+        ]);
+
+        return '';
+    }
+
+    public function viewDataForm ($iContentId, $sDisplay = false)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        // get content data and profile info
+        list ($oProfile, $aContentInfo) = $this->_getProfileAndContentData($iContentId);
+        if (!$aContentInfo)
+            return $this->_bIsApi ? bx_api_get_msg('_sys_txt_error_entry_is_not_defined') : MsgBox(_t('_sys_txt_error_entry_is_not_defined'));
+
+        // check access
+        if ($sMsg = $this->_processPermissionsCheckForViewDataForm ($aContentInfo, $oProfile))
+            return $this->_bIsApi ? bx_api_get_msg($sMsg) : MsgBox($sMsg);
+
+        // get form
+        $oForm = $this->getObjectFormView($sDisplay);
+        if (!$oForm)
+            return $this->_bIsApi ? bx_api_get_msg('_sys_txt_error_occured') : MsgBox(_t('_sys_txt_error_occured'));
+
+        // process metatags
+        $aSpecificValues =[];
+        if (!empty($CNF['OBJECT_METATAGS']) && ($oMetatags = BxDolMetatags::getObjectInstance($CNF['OBJECT_METATAGS'])) !== false) {
+            if ($oMetatags->keywordsIsEnabled()) {
+                $aFields = $oMetatags->metaFields($aContentInfo, $CNF, $CNF['OBJECT_FORM_ENTRY_DISPLAY_VIEW']);
+                $oForm->setMetatagsKeywordsData($iContentId, $aFields, $oMetatags);
+            }
+
+            if ($oMetatags->locationsIsEnabled())
+                $aSpecificValues = $oMetatags->locationGet($iContentId, empty($CNF['FIELD_LOCATION_PREFIX']) ? '' : $CNF['FIELD_LOCATION_PREFIX']);
+        }        
+
+        // display profile
+        $oForm->initChecker($aContentInfo, $aSpecificValues);
+        
+        if ($this->_bIsApi)
+            return [bx_api_get_block('entity_info', $oForm->getCodeAPI())];
+        
+        return $oForm->getCode();
+    }
+
+    protected function _processPermissionsCheckForViewDataForm ($aContentInfo, $oProfile)
+    {
+        if (CHECK_ACTION_RESULT_ALLOWED !== ($sMsg = $this->_oModule->checkAllowedView($aContentInfo)))
+            return $sMsg;
+
+        return '';
+    }
+
+    public function onDataDeleteAfter ($iContentId, $aContentInfo, $oProfile)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        if(($oPrivacy = BxDolPrivacy::getObjectInstance($CNF['OBJECT_PRIVACY_VIEW'])) !== false)
+            $oPrivacy->deleteGroupCustomByContentId($iContentId);
+
+        $sKey = 'FIELD_ALLOW_VIEW_TO';
+        if(isset($CNF[$sKey], $aContentInfo[$CNF[$sKey]]) && ($iContextPid = (int)$aContentInfo[$CNF[$sKey]]) < 0) {
+            $iContextPid = abs($iContextPid);
+            if(($oContext = BxDolProfile::getInstance($iContextPid)) !== false) {
+                $sModule = $oContext->getModule();
+                $sMethod = 'on_content_deleted';
+                if(bx_is_srv($sModule, $sMethod))
+                    bx_srv($sModule, $sMethod, [$this->_oModule->getName(), $iContentId, $oContext->getContentId()]);
+            }
+        }
+
+        bx_audit(
+            $iContentId, 
+            $this->_oModule->getName(), 
+            '_sys_audit_action_detete_content',  
+            $this->_oModule->_prepareAuditParams($aContentInfo)
+        );
+        
+        return '';
+    }
+
+    public function onDataEditBefore ($iContentId, $aContentInfo, &$aTrackTextFieldsChanges, &$oProfile, &$oForm)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        if(isset($CNF['FIELD_STATUS']) && isset($aContentInfo[$CNF['FIELD_STATUS']]) && $aContentInfo[$CNF['FIELD_STATUS']] == 'failed')
+            $oForm->addTrackFields($CNF['FIELD_STATUS'], $aContentInfo);
+    }
+
+    public function onDataEditAfter ($iContentId, $aContentInfo, $aTrackTextFieldsChanges, $oProfile, $oForm)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        /*
+         * Load update data.
+         */
+        list($oProfile, $aContentInfo) = $this->_getProfileAndContentData($iContentId);
+
+        if(isset($CNF['FIELD_PHOTO']))
+            $oForm->processFiles ($CNF['FIELD_PHOTO'], $iContentId, false);
+
+        if(isset($CNF['FIELD_STATUS']) && ($aTrackResult = $oForm->isTrackFieldChanged($CNF['FIELD_STATUS'], true)) !== false)
+            if($aTrackResult['old'] == 'failed' && $aTrackResult['new'] == 'active')
+                $this->_oModule->alertAfterAdd($aContentInfo);
+
+        if(isset($CNF['FIELD_ALLOW_VIEW_TO']) && !empty($aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']]) && ($oPrivacy = BxDolPrivacy::getObjectInstance($CNF['OBJECT_PRIVACY_VIEW'])) !== false)
+            $oPrivacy->reassociateGroupCustomWithContent($oProfile->id(), $iContentId, (int)$aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']]);
+        
+        bx_audit(
+            $iContentId, 
+            $this->_oModule->getName(), 
+            '_sys_audit_action_edit_content',  
+            $this->_oModule->_prepareAuditParams($aContentInfo)
+        );
+        
+        return '';
+    }
+
+    public function onDataAddAfter ($iAccountId, $iContentId)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        list($oProfile, $aContentInfo) = $this->_getProfileAndContentData($iContentId);
+
+        if(($oForm = $this->getObjectFormAdd()) !== false) {
+            if(isset($CNF['FIELD_PHOTO']))
+                $oForm->processFiles($CNF['FIELD_PHOTO'], $iContentId, true);
+        }
+
+        if(isset($CNF['FIELD_ALLOW_VIEW_TO']) && !empty($aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']]) && ($oPrivacy = BxDolPrivacy::getObjectInstance($CNF['OBJECT_PRIVACY_VIEW'])) !== false)
+            $oPrivacy->associateGroupCustomWithContent($oProfile->id(), $iContentId, (int)$aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']]);
+
+        bx_audit(
+            $iContentId, 
+            $this->_oModule->getName(), 
+            '_sys_audit_action_add_content',  
+            $this->_oModule->_prepareAuditParams($aContentInfo)
+        );
+        
+        return '';
+    }
+
+    protected function prepareCustomRedirectUrl($s, $aContentInfo)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+        $oProfile = BxDolProfile::getInstanceByContentAndType($aContentInfo[$CNF['FIELD_ID']], $this->_oModule->getName());
+
+        $aMarkers = array(
+            '{profile_id}',
+            '{content_id}',
+            '{module}',
+        );
+        $aReplacements = array(
+            $oProfile ? $oProfile->id() : 0,
+            $aContentInfo[$CNF['FIELD_ID']],
+            $this->_oModule->getName(),
+        );
+        $s = str_replace($aMarkers, $aReplacements, $s);
+
+        $s = bx_absolute_url(BxDolPermalinks::getInstance()->permalink($s));
+        
+        return $s;
+    }
+    
+    protected function prepareResponse($mixedResponse, $bAsJson = false, $sKey = 'msg', $aAdditional = array())
+    {
+    	if(!$bAsJson)
+            return $mixedResponse;
+
+        $aResponse = array(
+            $sKey => $mixedResponse,
+            '_dt' => 'json'
+        );
+
+        if(!empty($aAdditional) && is_array($aAdditional))
+            $aResponse = array_merge($aResponse, $aAdditional);
+
+        return $aResponse;
+    }
+    
+    protected function prepareResponseAPI($sType, $mixedData, $aParams = [])
+    {
+        $aResult = [];
+        switch($sType) {
+            case 'msg':
+                $aResult = bx_api_get_msg($mixedData);
+                break;
+
+            case 'form':
+                $sModule = $this->_oModule->getName();
+                $sRequestUrl = '/api.php?r=' . $sModule . '/' . $aParams['request_uri'];
+                if(!empty($aParams['request_params']) && is_array($aParams['request_params']))
+                    $sRequestUrl = bx_append_url_params($sRequestUrl, $aParams['request_params']);
+
+                $aResult = bx_api_get_block('form', $mixedData, [
+                    'ext' => [
+                        'name' => $sModule, 
+                        'request' => ['url' => $sRequestUrl, 'immutable' => true]
+                    ]
+                ]);
+                break;
+        }
+        
+        return [$aResult];
+    }
+
+    protected function _setAbsoluteActionUrl($sType, &$oForm)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $sUri = '';
+        if(($sKeyUri = 'URI_' . strtoupper($sType) . '_ENTRY') && !empty($CNF[$sKeyUri]))
+            $sUri = $CNF[$sKeyUri];
+        if(!$sUri && $sType == 'add' && ($sKeyUri = 'URI_EDIT_ENTRY') && !empty($CNF[$sKeyUri]))
+            $sUri = str_replace('edit', 'create', $CNF[$sKeyUri]);
+        if(!$sUri)
+            return;
+
+        $oForm->setAbsoluteActionUrl(bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $sUri)));
+    }
+
+    protected function _getRedirectFromContext($sAction, $aContentInfo)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $sKey = 'FIELD_ALLOW_VIEW_TO';
+        if(empty($CNF[$sKey]) || !isset($aContentInfo[$CNF[$sKey]]) || (int)$aContentInfo[$CNF[$sKey]] >= 0) 
+            return false;
+
+        $iContextPid = abs($aContentInfo[$CNF[$sKey]]);
+        $oContext = BxDolProfile::getInstance($iContextPid);
+        if(!$oContext)
+            return false;
+
+        $sModuleContext = $oContext->getModule();
+        $sMethodContext = 'on_content_' . $sAction . 'ed_redirect';
+        if(!bx_is_srv($sModuleContext, $sMethodContext))
+            return false;
+
+        return bx_srv($sModuleContext, $sMethodContext, [$this->_oModule->getName(), $aContentInfo[$CNF['FIELD_ID']]]);
+    }
+}
+
+/** @} */

+ 4544 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/modules/base/general/classes/BxBaseModGeneralModule.php

@@ -0,0 +1,4544 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    BaseGeneral Base classes for modules
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+define('BX_BASE_MOD_GENERAL_STATUS_ACTIVE', 'active');
+define('BX_BASE_MOD_GENERAL_STATUS_HIDDEN', 'hidden');
+define('BX_BASE_MOD_GENERAL_STATUS_PENDING', 'pending');
+
+bx_import('BxDolAcl');
+
+/**
+ * Base module class.
+ */
+class BxBaseModGeneralModule extends BxDolModule
+{
+    protected $_bIsApi;
+    protected $_iProfileId;
+    protected $_aSearchableNamesExcept;
+    protected $_aFormParams;
+
+    protected $_aBrowsingFiltersKeys;
+
+    function __construct(&$aModule)
+    {
+        parent::__construct($aModule);
+
+        $this->_bIsApi = bx_is_api();
+        $this->_iProfileId = bx_get_logged_profile_id();
+        $this->_aSearchableNamesExcept = array(
+            'allow_view_to'
+        );
+
+        $this->_aFormParams = array(
+            'display' => false, 
+            'dynamic_mode' => false, 
+            'ajax_mode' => false, 
+            'absolute_action_url' => false,
+            'visibility_autoselect' => false,
+            'context_id' => 0,
+            'custom' => array()
+        );
+
+        $this->_aBrowsingFiltersKeys = ['mode'];
+    }
+
+    // ====== ACTIONS METHODS
+
+    public function isEntryActive($aContentInfo)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $iProfileId = bx_get_logged_profile_id();
+        $bModerator = $this->_isModerator();
+
+        $mixedResult = null;
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_general-is_entry_active '{module_name}', 'is_entry_active' - hook to override whether an entry (content) active or not
+         * - $unit_name - module name
+         * - $action - equals `is_entry_active`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `viewer_id` - [int] viewer profile id
+         *      - `is_moderator` - [boolean] is viewer has moderator status or not
+         *      - `content_info` - [array] content info array as key&value pairs
+         *      - `override_result` - [boolean] by ref, boolean value which determines whether entry (content) active or not, can be overridden in hook processing
+         * @hook @ref hook-bx_base_general-is_entry_active
+         */
+        bx_alert($this->getName(), 'is_entry_active', 0, 0, [
+            'viewer_id' => $iProfileId, 
+            'is_moderator' => $bModerator, 
+            'content_info' => $aContentInfo, 
+            'override_result' => &$mixedResult
+        ]);
+        if($mixedResult !== null)
+            return $mixedResult;
+
+        if($this->isEntryAuthor($aContentInfo, $iProfileId) || $bModerator)
+            return true;
+
+        if(!empty($CNF['FIELD_ALLOW_VIEW_TO']) && (int)$aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']] < 0) {
+            $iContextProfileId = abs((int)$aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']]);
+            $oContextProfile = BxDolProfile::getInstance($iContextProfileId);
+
+            $aAdmins = bx_srv($oContextProfile->getModule(), 'get_admins_to_manage_content', [$iContextProfileId]);
+            if(!empty($aAdmins) && in_array($iProfileId, $aAdmins))
+                return true;
+        }
+
+        if(isset($CNF['FIELD_STATUS']) && $aContentInfo[$CNF['FIELD_STATUS']] != 'active')
+            return false;
+
+        if(isset($CNF['FIELD_STATUS_ADMIN']) && isset($aContentInfo[$CNF['FIELD_STATUS_ADMIN']]) && $aContentInfo[$CNF['FIELD_STATUS_ADMIN']] != 'active')
+            return false;
+
+        return true;        
+    }
+
+    public function isEntryAuthor($aContentInfo, $iProfileId = 0)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!$iProfileId)
+            $iProfileId = bx_get_logged_profile_id();
+
+        $mixedResult = null;
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_general-is_entry_author '{module_name}', 'is_entry_author' - hook to check if a viewer is an author of viewed entry (content) or not
+         * - $unit_name - module name
+         * - $action - equals `is_entry_author`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `viewer_id` - [int] viewer profile id
+         *      - `content_info` - [array] content info array as key&value pairs
+         *      - `override_result` - [boolean] by ref, boolean value which determines whether the viewer is an author of viewed entry (content) or not, can be overridden in hook processing
+         * @hook @ref hook-bx_base_general-is_entry_author
+         */
+        bx_alert($this->getName(), 'is_entry_author', 0, 0, [
+            'viewer_id' => $iProfileId, 
+            'content_info' => $aContentInfo, 
+            'override_result' => &$mixedResult
+        ]);
+        if($mixedResult !== null)
+            return $mixedResult;
+
+        return abs($aContentInfo[$CNF['FIELD_AUTHOR']]) == $iProfileId;
+    }
+
+    public function actionUpdateImage($sFiledName, $iContentId, $sValue)
+    {
+        $mixedResult = $this->serviceUpdateImage($sFiledName, $iContentId, $sValue);
+        if($mixedResult !== false)
+            echo $mixedResult;
+    }
+
+    public function actionUpdateImagePosition($iContentId, $sFiledName, $sH, $sV)
+    {
+        $this->serviceUpdateImagePosition($iContentId, $sFiledName, $sH, $sV);
+    }
+
+    public function actionApprove()
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!isset($CNF['FIELD_STATUS_ADMIN']))
+            return echoJson([]);
+
+        $iContentId = (int)bx_get('content_id');
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if(empty($aContentInfo) || !is_array($aContentInfo) || $aContentInfo[$CNF['FIELD_STATUS_ADMIN']] != BX_BASE_MOD_GENERAL_STATUS_PENDING)
+            return echoJson([]);
+
+        if(($mixedCheckResult = $this->checkAllowedApprove($aContentInfo)) !== CHECK_ACTION_RESULT_ALLOWED)
+            return echoJson(['msg' => $mixedCheckResult]);
+
+        $oForm = $this->_getApproveForm($iContentId, $aContentInfo);
+        $oForm->initChecker();
+
+        if(!$oForm->isSubmitted() || !$oForm->isValid()) {
+            $sPopupId = $this->getName() . '_approve_' . $iContentId;
+            $sPopupTitle = '_sys_manage_popup_approve_title';
+            if(!empty($CNF['T']['txt_approve_popup_title'])) 
+                $sPopupTitle = $CNF['T']['txt_approve_popup_title'];
+
+            return echoJson(['popup' => BxTemplFunctions::getInstance()->popupBox($sPopupId, _t($sPopupTitle), $oForm->getCode(true))]);
+        }
+
+        if($oForm->getCleanValue('do_send')) {
+            $aResult = [];
+            if($this->_sendApproveMessage($iContentId, $aContentInfo, $oForm))
+                $aResult = ['msg' => _t('_sys_manage_txt_sent')];
+            else
+                $aResult = ['msg' => _t('_sys_manage_err_cannot_perform_action')];
+
+            return echoJson($aResult);
+        }
+
+        if($oForm->getCleanValue('do_submit')) {
+            $aResult = [];
+            if($this->_oDb->updateEntriesBy([$CNF['FIELD_STATUS_ADMIN'] => BX_BASE_MOD_GENERAL_STATUS_ACTIVE], [$CNF['FIELD_ID'] => $iContentId])) {
+                $this->checkAllowedApprove($aContentInfo, true);
+
+                $this->onApprove($aContentInfo);
+
+                /*
+                 * Process metas.
+                 * Note. It's essential to process metas a the very end, 
+                 * because all data related to an entry should be already
+                 * processed and are ready to be passed to alert. 
+                 */
+                $this->processMetasAdd($iContentId);
+
+                $aResult = ['msg' => _t('_sys_manage_txt_approved'), 'reload' => 1];
+            }
+            else
+                $aResult = ['msg' => _t('_sys_manage_err_cannot_perform_action')];
+
+            return echoJson($aResult);
+        }
+    }
+
+    public function serviceSetStatus($iId, $sValue, $sStatus = '')
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!$sStatus && !empty($CNF['FIELD_STATUS_ADMIN']))
+            $sStatus = $CNF['FIELD_STATUS_ADMIN'];
+
+        $aStatuses = [];
+        if(!empty($CNF['FIELD_STATUS_ADMIN']))
+            $aStatuses[] = $CNF['FIELD_STATUS_ADMIN'];
+        if(!empty($CNF['FIELD_STATUS']))
+            $aStatuses[] = $CNF['FIELD_STATUS'];
+
+        if(!in_array($sStatus, $aStatuses))
+            return false;
+
+        if((int)$this->_oDb->updateEntriesBy([$sStatus => $sValue], [$CNF['FIELD_ID'] => $iId]) == 0)
+            return false;
+
+        if($sValue == BX_BASE_MOD_GENERAL_STATUS_ACTIVE)
+            $this->onPublished($iId);
+    }
+
+    public function actionRss ()
+    {
+        $aArgs = func_get_args();
+        $this->_rss($aArgs);
+    }
+
+    public function actionGetAttachLinkForm()
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $iContentId = ($iContentId = bx_get($CNF['FIELD_ATTACH_LINK_CONTENT_ID'])) !== false ? (int)$iContentId : 0;
+
+        echo $this->_oTemplate->getAttachLinkForm($iContentId);
+    }
+    
+    public function actionSubmitAttachLinkForm()
+    {
+        echoJson($this->getFormAttachLink());
+    }
+
+    public function actionAddAttachLink()
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $sUrl = bx_process_input(bx_get($CNF['FIELD_ATTACH_LINK_URL']));
+        if(empty($sUrl))
+            return echoJson([]);
+        
+        $sUrl = htmlspecialchars_decode($sUrl);
+        $oStreamContext = stream_context_create([
+            'http' => [
+                'timeout' => getParam('sys_default_socket_timeout'), 
+            ]
+        ]);
+
+        $sHeader = 'Content-Type';
+        $aHeaders = @get_headers($sUrl, 1, $oStreamContext);
+        if(!empty($aHeaders) && is_array($aHeaders) && !empty($aHeaders[$sHeader])) {
+            $mixedContentType = $aHeaders[$sHeader];
+            if(!is_array($mixedContentType))
+                $mixedContentType = [$mixedContentType];
+
+            foreach($mixedContentType as $sContentType)
+                if(strpos($sContentType, 'image') !== false) 
+                    return echoJson([]);
+        }
+
+        echoJson($this->addAttachLink([
+            $CNF['FIELD_ATTACH_LINK_CONTENT_ID'] => ($iContentId = bx_get($CNF['FIELD_ATTACH_LINK_CONTENT_ID'])) !== false ? (int)$iContentId : 0,
+            $CNF['FIELD_ATTACH_LINK_URL'] => $sUrl
+        ]));
+    }
+
+    public function actionDeleteAttachLink()
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+    	$iUserId = $this->getUserId();
+        $iLinkId = bx_process_input(bx_get('id'), BX_DATA_INT);
+        if(empty($iLinkId))
+            return echoJson([]);
+
+        $aLink = $this->_oDb->getLinksBy(['type' => 'id', 'id' => $iLinkId, 'profile_id' => $iUserId]);
+    	if(empty($aLink) || !is_array($aLink))
+            return echoJson([]);
+
+        if(!empty($aLink['media_id']))
+            BxDolStorage::getObjectInstance($CNF['OBJECT_STORAGE_PHOTOS'])->deleteFile($aLink['media_id']);
+
+        $aResult = [];
+        if($this->_oDb->deleteLink($iLinkId))
+            $aResult = ['code' => 0, 'url' => $aLink['url']];
+        else
+            $aResult = ['code' => 1, 'message' => _t(!empty($CNF['txt_link_form_err_delete']) ? $CNF['txt_link_form_err_delete'] : $this->getName() . '_form_post_input_link_err_delete')];
+
+        echoJson($aResult);
+    }
+
+    public function actionGetCreatePostForm()
+    {
+        $sName = $this->_oConfig->getName();
+
+    	$aParams = bx_process_input(array_intersect_key($_GET, $this->_aFormParams));
+    	$aParams = array_merge($this->_aFormParams, $aParams);
+        $aParams['context_id'] = (bool)$aParams['context_id'] ? (int)$aParams['context_id'] : false;
+
+    	$mixedResponse = $this->serviceGetCreatePostForm($aParams);
+    	if(empty($mixedResponse))
+            return echoJson(array());
+        else if(is_array($mixedResponse)) {
+            $mixedResponse['module'] = $sName;
+            return echoJson($mixedResponse);
+        }
+
+        return echoJson(array(
+            'module' => $sName,
+            'content' => $mixedResponse
+    	));
+    }
+
+    public function actionGetNotes()
+    {
+        $CNF = &$this->_oConfig->CNF;
+        if(empty($CNF['OBJECT_NOTES']))
+            return echoJson([]);
+
+        $iContentId = (int)bx_get('content_id');
+
+        $oCmtsNotes = BxDolCmts::getObjectInstance($CNF['OBJECT_NOTES'], $iContentId, true, $this->_oTemplate);
+        $aCmtsNotes = $oCmtsNotes->getCommentsBlock([], ['in_designbox' => false]);
+        if(empty($aCmtsNotes) || !is_array($aCmtsNotes))
+            return echoJson([]);
+
+        $sPostfix = $iContentId < 0 ? '_rc' : '';
+
+        $sPopupId = $this->getName() . '_notes_' . $iContentId;
+        $sPopupTitle = !empty($CNF['T']['txt_cmts_notes_popup_view_title' . $sPostfix]) ? $CNF['T']['txt_cmts_notes_popup_view_title' . $sPostfix] : '_cmt_nts_popup_view_title' . $sPostfix;
+        echoJson(['popup' => BxTemplFunctions::getInstance()->popupBox($sPopupId, _t($sPopupTitle), $aCmtsNotes['content'])]);
+    }
+    
+    public function actionNested()
+    {
+        $sAction = bx_get('a');
+        $sMethodName = 'subaction' . ucfirst($sAction);
+        if (!method_exists($this, $sMethodName)) {
+            return;
+        }
+        $this->$sMethodName();
+    }
+    
+    public function actionEmbed($iContentId, $sUnitTemplate = '', $sAddCode = '')
+    {
+        $oTemplate = BxDolTemplate::getInstance();
+        
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if(empty($aContentInfo))
+            $oTemplate->getEmbed(false);
+
+        if(empty($sUnitTemplate))
+            $sUnitTemplate = 'unit_gallery.html';
+
+        $CNF = &$this->_oConfig->CNF;
+        
+        if(!empty($CNF['OBJECT_VIEWS'])) {
+            $oView = BxDolView::getObjectInstance($CNF['OBJECT_VIEWS'], $iContentId);
+            if($oView && $oView->isEnabled())
+                $oView->doView();
+        }
+
+        $oTemplate->getEmbed($this->_oTemplate->unit($aContentInfo, true, $sUnitTemplate, ['embed' => true]) . $sAddCode);
+    }
+   
+    public function subactionDelete()
+    {
+        header('Content-type: text/html; charset=utf-8');
+
+        $iNestedId = (int)bx_get('id');
+		$sNestedForm = bx_get('s');
+        
+        $oForm = BxDolForm::getObjectInstance($sNestedForm, $sNestedForm);
+        if (!$oForm){
+            echo _t('_sys_request_page_not_found_cpt');
+            return;
+        }
+
+        $aNested = $this->_oDb->getNestedBy(array('type' => 'id', 'id' => $iNestedId, 'key_name' => $oForm->aParams['db']['key']), $oForm->aParams['db']['table']);
+        if (empty($aNested)){
+            echo _t('_sys_request_page_not_found_cpt');
+            return;
+        }
+        
+        $aContentInfo = $this->_oDb->getContentInfoById ($aNested['content_id']); 
+        if (!$aContentInfo){
+            echo _t('_sys_request_page_not_found_cpt');
+            return;
+        }
+
+        elseif (CHECK_ACTION_RESULT_ALLOWED !== ($sMsg = $this->checkAllowedEdit ($aContentInfo))) {
+            echo $sMsg;
+        } 
+        elseif (!$this->_oDb->deleteNestedById($iNestedId, $oForm->aParams['db']['key'], $oForm->aParams['db']['table'])) {
+            echo _t('_sys_txt_error_occured');
+        } 
+        else {
+            echo 'ok';
+        }
+    }
+    
+    /**
+     * Get possible recipients for start conversation form
+     */
+    public function actionAjaxGetProfiles ()
+    {
+        $sTerm = bx_get('term');
+
+        $a = BxDolService::call('system', 'profiles_search', array($sTerm), 'TemplServiceProfiles');
+
+        header('Content-Type:text/javascript; charset=utf-8');
+        echo(json_encode($a));
+    }
+    
+    public function serviceGetProfiles ($sParams)
+    {
+        $aOptions = json_decode($sParams, true);
+        if (!$sParams || !isset($aOptions['term']))
+            return [];
+
+        return BxDolService::call('system', 'profiles_search', array($aOptions['term']), 'TemplServiceProfiles');
+    }
+
+    public function actionGetBrowsingFilters()
+    {
+        $aParams = $this->_prepareBrowsingFiltersParamsGet();
+
+        $sContent = $this->_oTemplate->getBrowsingFilters($aParams);
+        if(empty($sContent))
+            return echoJson([]);
+
+        echoJson([
+            'code' => 0,
+            'popup' => [
+                'html' => $sContent,
+                'options' => [
+                    'closeOnOuterClick' => false,
+                    'removeOnClose' => false,
+                ]
+            ],
+        ]);
+    }
+
+    public function actionApplyBrowsingFilters()
+    {
+        $aParams = $this->_prepareBrowsingFiltersParamsGet();
+
+        $aBrowseParams = ['mode' => $aParams['mode'], 'empty_message' => true];
+        unset($aParams['mode']);
+        $aBrowseParams['params'] = $aParams;
+
+        $mixedResult = $this->serviceBrowse($aBrowseParams);
+        if(empty($mixedResult))
+            return echoJson([]);
+
+        if(is_array($mixedResult) && isset($mixedResult['content']))
+            $mixedResult = $mixedResult['content'];
+
+        return echoJson([
+            'code' => 0, 
+            'content' => $mixedResult, 
+            'eval' => $this->_oConfig->getJsObject('main') . '.onApplyBrowsingFilter(oData)'
+        ]);
+    }
+
+    protected function _prepareBrowsingFiltersParamsGet($mParams = false)
+    {
+        $aParams = [];
+        $bParams = !empty($mParams) && is_array($mParams);
+
+        foreach($this->_aBrowsingFiltersKeys as $sKey)
+            if($bParams) {
+                if(isset($mParams[$sKey]))
+                    $aParams[$sKey] = $mParams[$sKey];
+            }
+            else {
+                if(($sValue = bx_get($sKey)) !== false)
+                    $aParams[$sKey] = $sValue;
+            }
+
+        return $aParams;
+    }
+
+    // ====== SERVICE METHODS
+
+    /**
+     * Checkes whether a content is active by status (status_admin).
+     * 
+     * @param type $iContentId
+     * @return boolean
+     */
+    public function serviceIsActive($iContentId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if(empty($aContentInfo))
+            return false;
+
+        if(($sKey = 'FIELD_STATUS') && isset($CNF[$sKey]) && $aContentInfo[$CNF[$sKey]] != 'active')
+            return false;
+
+        if(($sKey = 'FIELD_STATUS_ADMIN') && isset($CNF[$sKey]) && isset($aContentInfo[$CNF[$sKey]]) && $aContentInfo[$CNF[$sKey]] != 'active')
+            return false;
+
+        return true;
+    }
+
+    public function serviceIsAllowedAddContentToContext($iContextPid)
+    {
+        $oContext = false;
+        if(!$iContextPid || !($oContext = BxDolProfile::getInstance((int)$iContextPid)))
+            return false;
+
+        if($iContextPid == bx_get_logged_profile_id())
+            return true;
+
+        return bx_srv($oContext->getModule(), 'check_allowed_post_in_profile', [$oContext->getContentId(), $this->getName()]) === CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    public function serviceIsBadgesAvaliable()
+    {
+        if(!isAdmin() && !$this->_isAdministrator())
+            return false;
+
+        $aBadges = BxDolBadges::getInstance()->getData([
+            'type' => 'by_module&object', 
+            'object_id' => 0, 
+            'module' => $this->getName()
+        ]);
+
+        return count($aBadges) > 0 ? true : false;
+    }
+
+    public function serviceGetSafeServices()
+    {
+        return array(
+            // other
+            'ModuleIcon' => '',
+            'GetLink' => '',
+            // browse
+            'GetSearchResultUnit' => '',
+            'Browse' => '',
+            'BrowseFeatured' => '',
+            'BrowseFavorite' => '',
+            // forms
+            'GetCreatePostForm' => '',
+            'EntityCreate' => '',
+            'EntityEdit' => '',
+            'EntityDelete' => '',
+            'UpdateImage' => '',
+            // page blocks
+            'EntityTextBlock' => '',
+            'EntityInfo' => '',
+            'EntityInfoFull' => '',
+            'EntityInfoExtended' => '',
+            'EntityLocation' => '',
+            'EntityComments' => '',
+            'EntityAttachments' => '',
+            'CategoriesMultiList' => '',
+            // menu
+            'EntityAllActions' => '',
+            'EntityActions' => '',
+            'EntitySocialSharing' => '',
+            'MyEntriesActions' => '',
+            
+            'GetProfiles' => '',
+        );
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-other Other
+     * @subsubsection bx_base_general-module_icon module_icon
+     * 
+     * @code bx_srv('bx_posts', 'module_icon', [...]); @endcode
+     * 
+     * Get module icon as CSS class name with FontAwesome icon name class and color class
+     * 
+     * @see BxBaseModGeneralModule::serviceModuleIcon
+     */
+    /** 
+     * @ref bx_base_general-module_icon "module_icon"
+     */
+    public function serviceModuleIcon ()
+    {
+        return isset($this->_oConfig->CNF['ICON']) ? $this->_oConfig->CNF['ICON'] : '';
+    }
+
+    public function serviceGetAuthor ($iContentId)
+    {
+        $mixedResult = $this->_getFieldValue('FIELD_AUTHOR', $iContentId);
+        return $mixedResult !== false ? (int)$mixedResult : 0; 
+    }
+
+    public function serviceGetPrivacyView ($iContentId)
+    {
+        return $this->_getFieldValue('FIELD_ALLOW_VIEW_TO', $iContentId);
+    }
+
+    public function serviceGetDateAdded ($iContentId)
+    {
+        $mixedResult = $this->_getFieldValue('FIELD_ADDED', $iContentId);
+        return $mixedResult !== false ? (int)$mixedResult : 0; 
+    }
+
+    public function serviceGetDateChanged ($iContentId)
+    {
+        $mixedResult = $this->_getFieldValue('FIELD_CHANGED', $iContentId);
+        return $mixedResult !== false ? (int)$mixedResult : 0;
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-other Other
+     * @subsubsection bx_base_general-get_link get_link
+     * 
+     * @code bx_srv('bx_posts', 'get_link', [...]); @endcode
+     * 
+     * Get URL for the specified content.
+     * @param $iContentId content id
+     * 
+     * @see BxBaseModGeneralModule::serviceGetLink
+     */
+    /** 
+     * @ref bx_base_general-get_link "get_link"
+     */
+    public function serviceGetLink ($iContentId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        if(empty($CNF['URI_VIEW_ENTRY']))
+            return '';
+
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if(empty($aContentInfo))
+            return '';
+
+        return bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $CNF['URI_VIEW_ENTRY'] . '&id=' . $aContentInfo[$CNF['FIELD_ID']]));
+    }
+
+    public function serviceGetTitle ($iContentId)
+    {
+        $mixedResult = $this->_getFieldValue('FIELD_TITLE', $iContentId);
+        return $mixedResult !== false ? $mixedResult : '';
+    }
+
+    public function serviceGetText ($iContentId)
+    {
+        $mixedResult = $this->_getFieldValue('FIELD_TEXT', $iContentId);
+        if (false === $mixedResult)
+            return '';
+
+        $CNF = &$this->_oConfig->CNF;
+        if (!empty($CNF['OBJECT_METATAGS']) && is_string($mixedResult)) {
+            $oMetatags = BxDolMetatags::getObjectInstance($CNF['OBJECT_METATAGS']);
+            $mixedResult = $oMetatags->metaParse($iContentId, $mixedResult);
+        }
+
+        return $mixedResult;
+    }
+    
+    public function serviceGetLocation ($iContentId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(empty($CNF['OBJECT_METATAGS']))
+            return [];
+
+        $mixedResult = $this->_getFieldValue('FIELD_LOCATION', $iContentId);
+        if(false === $mixedResult)
+            return [];
+
+        $oMeta = false;
+        if(!($oMeta = BxDolMetatags::getObjectInstance($CNF['OBJECT_METATAGS'])) || !$oMeta->locationsIsEnabled())
+            return false;
+
+        $aLocation = $oMeta->locationGet($iContentId);
+        if(empty($aLocation) || !is_array($aLocation))
+            return false;
+
+        return array_values(array_slice($aLocation, 1));
+    }
+
+    public function serviceGetEmbed ($iContentId)
+    {
+        $sTitle = $this->_getFieldValue('FIELD_TITLE', $iContentId);
+        return $this->_oTemplate->parseHtmlByName('embed.html', [
+            'title' => $sTitle,
+            'url' => BX_DOL_URL_ROOT . $this->_oConfig->getBaseUri() . 'embed/' . $iContentId . '/'
+        ]);
+    }
+
+    public function serviceGetInfo ($iContentId, $bSearchableFieldsOnly = true)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $aContentInfo = $this->_getFields($iContentId);
+        if(empty($aContentInfo))
+            return array();
+
+        if(!$bSearchableFieldsOnly)
+            return $aContentInfo;
+
+        if(empty($CNF['PARAM_SEARCHABLE_FIELDS']))
+            return array();
+
+        $aFields = explode(',', getParam($CNF['PARAM_SEARCHABLE_FIELDS']));
+        if(empty($aFields))
+            return array();
+
+        $aResult = array();
+        foreach($aFields as $sField)
+            if(isset($aContentInfo[$sField]))
+                $aResult[$sField] = $aContentInfo[$sField];
+
+        return $aResult;
+    }
+
+    public function serviceGetInfoApi ($iContentId, $bExtendedUnits = false)
+    {
+        $aData = $this->serviceGetInfo($iContentId, false);
+        if($aData)
+            $aData = $this->getDataAPI($aData, ['extended' => $bExtendedUnits]);
+
+        return $aData;
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-browsing Browsing
+     * @subsubsection bx_base_general-get_search_result_unit get_search_result_unit
+     * 
+     * @code bx_srv('bx_posts', 'get_search_result_unit', [3,"unit_gallery.html"]); @endcode
+     * 
+     * Get browsing unit for the specified content
+     * @param $iContentId content id
+     * @param $sUnitTemplate unit template, such as: unit_full.html, unit_gallery.html, unit_live_search.html
+     * 
+     * @see BxBaseModGeneralModule::serviceGetSearchResultUnit
+     */
+    /** 
+     * @ref bx_base_general-get_search_result_unit "get_search_result_unit"
+     */
+    public function serviceGetSearchResultUnit ($iContentId, $sUnitTemplate = '')
+    {
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if($this->_bIsApi)
+            return $this->getDataAPI($aContentInfo);
+
+        if(empty($aContentInfo))
+            return '';
+
+        if(empty($sUnitTemplate))
+            $sUnitTemplate = 'unit.html';
+
+        return $this->_oTemplate->unit($aContentInfo, true, $sUnitTemplate);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-browsing Browsing
+     * @subsubsection bx_base_general-get_view get_view
+     * 
+     * @code bx_srv('bx_posts', 'get_view', [3]); @endcode
+     * 
+     * Get view for the specified content
+     * @param $iContentId content id
+     * 
+     * @see BxBaseModGeneralModule::serviceGetView
+     */
+    /** 
+     * @ref bx_base_general-get_view "get_view"
+     */
+    public function serviceGetView ($iContentId)
+    {
+        return $this->serviceEntityTextBlock($iContentId);
+    }
+
+    public function serviceGetAll ($aParams = array())
+    {
+        if(empty($aParams) || !is_array($aParams))
+            $aParams = array('type' => 'all');
+
+        return $this->_oDb->getEntriesBy($aParams);
+    }
+
+    public function serviceGetAllByAuthor ($iProfileId)
+    {
+        return $this->_oDb->getEntriesByAuthor((int)$iProfileId);
+    }
+
+    public function serviceGetSortableFieldsExtended($aInputsAdd = array())
+    {
+        $CNF = &$this->_oConfig->CNF;
+        if(empty($CNF['OBJECT_FORM_ENTRY']))
+            return array();
+        
+        $aResult = array();
+        
+        $aSortableFields = [];
+        
+        $aDisplays = [];
+        if(!empty($CNF['OBJECT_FORM_ENTRY_DISPLAY_ADD'])) {
+            $aDisplays[] = $CNF['OBJECT_FORM_ENTRY_DISPLAY_ADD'];
+        }
+        if(!empty($CNF['OBJECT_FORM_ENTRY_DISPLAY_EDIT'])) {
+            $aDisplays[] = $CNF['OBJECT_FORM_ENTRY_DISPLAY_EDIT'];
+        }
+        
+        $aInputsAll = BxDolFormQuery::getFormInputs($CNF['OBJECT_FORM_ENTRY']);    
+        foreach($aInputsAll as $aInput){
+            $aInputsAll[$aInput['name']] = $aInput;
+        }
+        
+        $aInputs = BxDolFormQuery::getFormInputs($CNF['OBJECT_FORM_ENTRY'],  $aDisplays);
+        foreach($aInputs as $aInput){
+            $aInputs[$aInput['name']] = $aInput;
+            if ($aInput['type'] == 'text'){
+                $aSortableFields[] = $aInput['name'];
+            }
+        }
+        
+        if(!empty($CNF['FIELD_ADDED']) && isset($aInputsAll[$CNF['FIELD_ADDED']])){
+           $aSortableFields[] = $CNF['FIELD_ADDED'];
+        }
+        
+        if(!empty($CNF['FIELD_CHANGED']) && isset($aInputsAll[$CNF['FIELD_CHANGED']])){
+           $aSortableFields[] = $CNF['FIELD_CHANGED'];
+        }     
+        
+        foreach($aSortableFields as $sSortableField){
+             $aResult[$sSortableField . '_asc'] = [
+                'name' => $sSortableField,
+                'caption' => $aInputsAll[$sSortableField]['caption'],
+                'direction' => 'asc'
+            ];
+            
+            $aResult[$sSortableField . '_desc']  = [
+                'name' => $sSortableField,
+                'caption' => $aInputsAll[$sSortableField]['caption'],
+                'direction' => 'desc'
+            ];
+        }
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_general-get_sortable_fields '{module_name}', 'get_sortable_fields' - hook to override a list of sortable fields
+         * - $unit_name - module name
+         * - $action - equals `get_sortable_fields`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `override_result` - [array] a list of searchable fields, can be overridden in hook processing
+         * @hook @ref hook-bx_base_general-get_sortable_fields
+         */
+        bx_alert($this->getName(), 'get_sortable_fields', 0, 0, [
+            'extended' => true, 
+            'override_result' => &$aResult
+        ]);
+
+        return $aResult;
+    }
+    
+    public function serviceGetSearchableFieldsExtended($aInputsAdd = array())
+    {
+        $CNF = &$this->_oConfig->CNF;
+        if(empty($CNF['OBJECT_FORM_ENTRY']))
+            return array();
+
+        $aResult = array();
+        if(!empty($CNF['FIELD_AUTHOR']) && !in_array($CNF['FIELD_AUTHOR'], $this->_aSearchableNamesExcept))
+            $aResult[$CNF['FIELD_AUTHOR']] = array(
+                'type' => 'text_auto', 
+                'caption' => $CNF['T']['form_field_author'],
+                'info' => '',
+            	'value' => '',
+                'values' => '',
+                'pass' => ''
+            );
+
+        $aInputs = array();
+        if(!empty($CNF['OBJECT_FORM_ENTRY_DISPLAY_ADD'])) {
+            $oForm = BxDolForm::getObjectInstance($CNF['OBJECT_FORM_ENTRY'], $CNF['OBJECT_FORM_ENTRY_DISPLAY_ADD'], $this->_oTemplate);
+
+            $aInputs = array_merge($aInputs, $oForm->aInputs);
+        }
+        if(!empty($CNF['OBJECT_FORM_ENTRY_DISPLAY_EDIT'])) {
+            $oForm = BxDolForm::getObjectInstance($CNF['OBJECT_FORM_ENTRY'], $CNF['OBJECT_FORM_ENTRY_DISPLAY_EDIT'], $this->_oTemplate);
+
+            $aInputs = array_merge($aInputs, $oForm->aInputs);
+        }
+
+        if(!empty($aInputsAdd) && is_array($aInputsAdd))
+            $aInputs = array_merge($aInputs, $aInputsAdd);
+
+        if(!empty($CNF['FIELD_LABELS']) && array_key_exists($CNF['FIELD_LABELS'], $aInputs) && !in_array($CNF['FIELD_LABELS'], $this->_aSearchableNamesExcept))
+            $aResult[$CNF['FIELD_LABELS']] = [
+                'type' => 'checkbox_set', 
+                'caption' => isset($CNF['T']['form_field_labels']) ? $CNF['T']['form_field_author'] : '_sys_form_input_labels',
+                'info' => '',
+            	'value' => '',
+                'values' => '',
+                'pass' => '',
+                'search_operator' => 'like'
+            ];
+
+        foreach($aInputs as $aInput){
+            if(in_array($aInput['type'], BxDolSearchExtended::$SEARCHABLE_TYPES) && !in_array($aInput['name'], $this->_aSearchableNamesExcept)) {
+                $aField = array(
+                    'type' => $aInput['type'], 
+                    'caption_system' => $aInput['caption_system_src'],
+                    'caption' => $aInput['caption_src'],
+                    'info' => $aInput['info_src'],
+                    'value' => !empty($aInput['value']) ? $aInput['value'] : '',
+                    'values' => !empty($aInput['values_src']) ? $aInput['values_src'] : '',
+                    'pass' => !empty($aInput['db']['pass']) ? $aInput['db']['pass'] : '',
+                );
+
+                if(isset($aInput['search_type']))
+                    $aField['search_type'] = $aInput['search_type'];
+
+                if(isset($aInput['search_operator']))
+                    $aField['search_operator'] = $aInput['search_operator'];
+
+                $aResult[$aInput['name']] = $aField;
+            }
+        }
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_general-get_searchable_fields '{module_name}', 'get_searchable_fields' - hook to override a list of searchable fields
+         * - $unit_name - module name
+         * - $action - equals `get_searchable_fields`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `override_result` - [array] a list of searchable fields, can be overridden in hook processing
+         * @hook @ref hook-bx_base_general-get_searchable_fields
+         */
+        bx_alert($this->getName(), 'get_searchable_fields', 0, 0, [
+            'extended' => true, 
+            'override_result' => &$aResult
+        ]);
+
+        return $aResult;
+    }
+
+    public function serviceGetSearchResultExtended($aParams, $iStart = 0, $iPerPage = 0, $bFilterMode = false)
+    {
+        if((empty($aParams) || !is_array($aParams)) && !$bFilterMode)
+            return array();
+
+        return $this->_oDb->getEntriesBy(array('type' => 'search_ids', 'search_params' => $aParams, 'start' => $iStart, 'per_page' => $iPerPage));
+    }
+
+    public function serviceGetSearchableFields ($aInputsAdd = array())
+    {
+        $CNF = $this->_oConfig->CNF;
+
+        if(!isset($CNF['PARAM_SEARCHABLE_FIELDS']) || !isset($CNF['OBJECT_FORM_ENTRY']) || !isset($CNF['OBJECT_FORM_ENTRY_DISPLAY_ADD']))
+            return array();
+
+        $oForm = BxDolForm::getObjectInstance($CNF['OBJECT_FORM_ENTRY'], $CNF['OBJECT_FORM_ENTRY_DISPLAY_ADD'], $this->_oTemplate);
+        if(!$oForm)
+            return array();
+
+        $aInputs = $oForm->aInputs;
+        if(!empty($aInputsAdd) && is_array($aInputsAdd))
+            $aInputs = array_merge($aInputs, $aInputsAdd);
+
+        $aTextTypes = array('text', 'textarea');
+        $aTextFields = array();
+        foreach($aInputs as $r)
+            if(in_array($r['type'], $aTextTypes))
+                $aTextFields[$r['name']] = $r['caption'];
+
+        return $aTextFields;
+    }
+
+    public function serviceUpdateImage($sFiledName, $iContentId, $sValue)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $aData = $this->_oDb->getContentInfoById($iContentId);
+        if(empty($aData) || !is_array($aData))
+            return false;
+
+        if($this->checkAllowedEdit($aData) !== CHECK_ACTION_RESULT_ALLOWED)
+            return false;
+
+        //TODO add transcoder settings
+        $oImgStorage = BxDolStorage::getObjectInstance($CNF['OBJECT_STORAGE']);
+        if(!$oImgStorage)
+            return false;
+
+        $this->_oDb->updateEntriesBy([$sFiledName => $sValue], [$CNF['FIELD_ID'] => $iContentId]);
+
+        $this->serviceUpdateImagePosition($iContentId, $sFiledName, '', '');
+
+        $this->onUpdateImage($iContentId, $sFiledName, $sValue);
+
+        return $oImgStorage->getFileUrlById($sValue);
+    }
+
+    public function serviceUpdateImagePosition($iContentId, $sFiledName, $sH, $sV)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $sFieldNamePos = 'FIELD_' . strtoupper($sFiledName) . '_POSITION';
+        if(!isset($CNF[$sFieldNamePos]))
+            return false;
+
+        $sValue = '';
+        if($sH != '' || $sV != '')
+            $sValue = json_encode(['x' => $sH, 'y' => $sV]);
+
+        $this->_oDb->updateEntriesBy([$CNF[$sFieldNamePos] => $sValue], [$CNF['FIELD_ID'] => $iContentId]);
+
+        return true;
+    }
+
+    public function serviceManageTools($sType = 'common')
+    {
+        if(!isLogged())
+            return '';
+
+        $oGrid = BxDolGrid::getObjectInstance($this->_oConfig->getGridObject($sType));
+        if(!$oGrid)
+            return '';
+
+        if($this->_bIsApi)
+            return [
+                bx_api_get_block('grid', $oGrid->getCodeAPI())
+            ];
+
+        $CNF = &$this->_oConfig->CNF;
+
+        $mixedMenu = '';
+        if(BxDolAcl::getInstance()->isMemberLevelInSet(192)) {
+            $oPermalink = BxDolPermalinks::getInstance();
+
+            $aMenuItems = array();
+            if(!empty($CNF['OBJECT_GRID_COMMON']) && !empty($CNF['T']['menu_item_manage_my']))
+                $aMenuItems[] = array('id' => 'manage-common', 'name' => 'manage-common', 'class' => '', 'link' => $oPermalink->permalink($CNF['URL_MANAGE_COMMON']), 'target' => '_self', 'title' => _t($CNF['T']['menu_item_manage_my']), 'active' => 1);
+            if(!empty($CNF['OBJECT_GRID_ADMINISTRATION']) && !empty($CNF['T']['menu_item_manage_all']))
+                $aMenuItems[] = array('id' => 'manage-administration', 'name' => 'manage-administration', 'class' => '', 'link' => $oPermalink->permalink($CNF['URL_MANAGE_ADMINISTRATION']), 'target' => '_self', 'title' => _t($CNF['T']['menu_item_manage_all']), 'active' => 1);
+
+            if(count($aMenuItems) > 1) {
+                $mixedMenu = new BxTemplMenu(array(
+                    'template' => 'menu_block_submenu_ver.html', 
+                    'menu_items' => $aMenuItems
+                ), $this->_oTemplate);
+                $mixedMenu->setSelected($this->_aModule['name'], 'manage-' . $sType);
+            }
+        }
+
+        if(!empty($CNF['OBJECT_MENU_SUBMENU']) && isset($CNF['URI_MANAGE_COMMON']) && ($oSubmenu = BxDolMenu::getObjectInstance($CNF['OBJECT_MENU_SUBMENU'])) !== false)
+            $oSubmenu->setSelected($this->_aModule['name'], $CNF['URI_MANAGE_COMMON']);
+
+        $this->_oTemplate->addCss(array('manage_tools.css'));
+        $this->_oTemplate->addJs(array('manage_tools.js'));
+        $this->_oTemplate->addJsTranslation(array('_sys_grid_search'));
+        return array(
+            'content' => $this->_oTemplate->getJsCode('manage_tools', array('sObjNameGrid' => $this->_oConfig->getGridObject($sType))) . $oGrid->getCode(),
+            'menu' => $mixedMenu
+        );
+    }
+
+    public function serviceGetMenuAddonManageTools()
+    {
+    	return 0;
+    }
+
+    public function serviceGetMenuAddonManageToolsProfileStats($iProfileId = 0)
+    {
+    	return 0;
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-browsing Browsing
+     * @subsubsection bx_base_general-browse browse
+     * 
+     * @code bx_srv('bx_posts', 'browse', [...]); @endcode
+     * @code {{~bx_posts:browse[{"mode":"author", "params":{"author":"10"}}]~}} @endcode
+     * 
+     * Universal browse method.
+     * @param $aParams custom browse params, possible params are the following:
+     *  - mode - browse mode, such as 'recent', 'featured', etc
+     *  - params - custom params to browse method, for example 'unit_view' can be passed here
+     *  - design_box - design box style, @see BxBaseFunctions::DesignBoxContent 
+     *  - empty_message - display or not "empty" message when there is no content
+     *  - ajax_paginate - use AJAX paginate or not
+     * 
+     * @see BxBaseModGeneralModule::serviceBrowse
+     */
+    /** 
+     * @ref bx_base_general-browse "browse"
+     */
+    public function serviceBrowse ($aParams = array())
+    {
+        if($this->_bIsApi && is_string($aParams)){
+            $aParams = json_decode($aParams, true);
+            if (isset($aParams['params'])) {
+                if(isset($aParams['params']['type']))
+                    $aParams['mode'] = $aParams['params']['type'];
+
+                if(isset($aParams['params']['filters'])) {
+                    foreach($aParams['params']['filters'] as $sKey => $sValue)
+                        $aParams['params'][$sKey] = $sValue;
+
+                    $aParams['params']['filters'] = [
+                        'values' => $aParams['params']['filters']
+                    ];
+                }
+
+                if(isset($aParams['params']['validate']) && !is_array($aParams['params']['validate']))
+                    $aParams['params']['validate'] = !empty($aParams['params']['validate']) ? explode(',', $aParams['params']['validate']) : [];
+            }
+        }
+
+        if (isset($aParams['params']['condition']))
+            unset($aParams['params']['condition']);
+
+        return $this->serviceBrowseWithCondition ($aParams);
+    }
+
+    public function serviceBrowseWithCondition ($aParams = array())
+    {
+        $aDefaults = array (
+            'mode' => 'recent',
+            'params' => false,
+            'design_box' => BX_DB_PADDING_DEF,
+            'empty_message' => false,
+            'ajax_paginate' => true,
+            'class_search_result' => 'SearchResult'
+        );
+
+        $aParams = array_merge($aDefaults, $aParams);
+        
+        return $this->_serviceBrowse ($aParams['mode'], $aParams['params'], $aParams['design_box'], $aParams['empty_message'], $aParams['ajax_paginate'], $aParams['class_search_result']);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-browsing Browsing
+     * @subsubsection bx_base_general-browse_featured browse_featured
+     * 
+     * @code bx_srv('bx_posts', 'browse_featured', [...]); @endcode
+     * 
+     * Display featured entries
+     * @param $sUnitView browsing unity view, 
+     *                   such as: full, extended, gallery, showcase
+     * @param $bEmptyMessage display or not "empty" message when there is no content
+     * @param $bAjaxPaginate use AJAX paginate or not
+     * 
+     * @see BxBaseModGeneralModule::serviceBrowseFeatured
+     */
+    /** 
+     * @ref bx_base_general-browse_featured "browse_featured"
+     */
+    public function serviceBrowseFeatured ($sUnitView = false, $bEmptyMessage = false, $bAjaxPaginate = true)
+    {
+        return $this->_serviceBrowse ('featured', $sUnitView ? array('unit_view' => $sUnitView) : false, BX_DB_PADDING_DEF, $bEmptyMessage, $bAjaxPaginate);
+    }
+	
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-browsing Browsing
+     * @subsubsection bx_base_general-browse_favorite browse_favorite
+     * 
+     * @code bx_srv('bx_posts', 'browse_favorite', [...]); @endcode
+     * 
+     * Display entries which were added to favorite list by some member
+     * @param $iProfileId profile ID
+     * @param $aParams additional browse params, see BxBaseModGeneralModule::serviceBrowse
+     * 
+     * @see BxBaseModGeneralModule::serviceBrowseFavorite BxBaseModGeneralModule::serviceBrowse
+     */
+    /** 
+     * @ref bx_base_general-browse_favorite "browse_favorite"
+     */
+    public function serviceBrowseFavorite ($iProfileId = 0, $aParams = array())
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $oProfile = null;
+        if((int)$iProfileId)
+            $oProfile = BxDolProfile::getInstance($iProfileId);
+        if(!$oProfile && bx_get('profile_id') !== false)
+            $oProfile = BxDolProfile:: getInstance(bx_process_input(bx_get('profile_id'), BX_DATA_INT));
+        if(!$oProfile)
+            $oProfile = BxDolProfile::getInstance();
+        if(!$oProfile)
+            return '';
+
+        $bEmptyMessage = true;
+        if(isset($aParams['empty_message'])) {
+            $bEmptyMessage = (bool)$aParams['empty_message'];
+            unset($aParams['empty_message']);
+        }
+        
+        if(bx_get('list_id') !== false && !isset($aParams['list_id']))
+            $aParams['list_id'] = (int) bx_get('list_id');        
+
+        $aBlock = $this->_serviceBrowse ('favorite', array_merge(array('user' => $oProfile->id()), $aParams), BX_DB_PADDING_DEF, $bEmptyMessage);
+        if(!$aBlock)
+            return $aBlock;
+
+        $sTitle = _t('_sys_txt_default_favorite_list');
+        if(!empty($aParams['list_id']) && ($oFavorite = BxDolFavorite::getObjectInstance($CNF['OBJECT_FAVORITES'], 0, true))) {
+            $aList = $oFavorite->getQueryObject()->getList(['type' => 'id', 'list_id' => $aParams['list_id']]);
+            if(!empty($aList) && is_array($aList))
+                $sTitle = $aList['title'];
+        }
+
+        return array_merge($aBlock, ['markers' => [
+            'title' => $sTitle
+        ]]);
+    }
+    
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-browsing Browsing
+     * @subsubsection bx_base_general-browse_favorite_lists browse_favorite_lists
+     * 
+     * @code bx_srv('bx_posts', 'browse_favorite_lists', [...]); @endcode
+     * 
+     * Display entries which were added to favorite lists grouped by lists by some member
+     * @param $iProfileId profile ID
+     * @param $aParams additional browse params, see BxBaseModGeneralModule::serviceBrowse
+     * 
+     * @see BxBaseModGeneralModule::serviceBrowseFavoriteLists BxBaseModGeneralModule::serviceBrowse
+     */
+    /** 
+     * @ref bx_base_general-browse_favorite_lists "browse_favorite_lists"
+     */
+    public function serviceBrowseFavoriteLists ($iProfileId = 0, $aParams = array())
+    {
+        $CNF = &$this->_oConfig->CNF;
+        
+        $oProfile = null;
+        if((int)$iProfileId)
+            $oProfile = BxDolProfile::getInstance($iProfileId);
+        if(!$oProfile && bx_get('profile_id') !== false)
+            $oProfile = BxDolProfile:: getInstance(bx_process_input(bx_get('profile_id'), BX_DATA_INT));
+        if(!$oProfile)
+            $oProfile = BxDolProfile::getInstance();
+        if(!$oProfile)
+            return '';
+        
+        $iStart = (bx_get('list_start') !== false ? (int)bx_get('list_start') : 0);
+        $iPerPage = (int)getParam($CNF['PARAM_PER_PAGE_FOR_FAVORITES_LISTS']);
+        
+        return $this->_oTemplate->getFavoriteList($oProfile, $iStart, $iPerPage, $aParams);
+    }
+    
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-browsing Browsing
+     * @subsubsection bx_base_general-browse_by_category category
+     * 
+     * @code bx_srv('bx_posts', 'browse_by_category', [...]); @endcode
+     * 
+     * Display entries in category
+     * @param $sUnitView browsing unity view, 
+     *                   such as: full, extended, gallery, showcase
+     * @param $bEmptyMessage display or not "empty" message when there is no content
+     * @param $bAjaxPaginate use AJAX paginate or not
+     * 
+     * @see BxBaseModGeneralModule::serviceBrowseByCategory BxBaseModGeneralModule::serviceBrowse
+     */
+    /** 
+     * @ref bx_base_general-browse_by_category "browse_by_category"
+     */
+    public function serviceBrowseCategory($sUnitView = false, $bEmptyMessage = true, $bAjaxPaginate = true, $aParams = [])
+    {
+        $sType = 'category';
+
+        $aParams = array_merge(['category' => bx_process_input(bx_get('category'), BX_DATA_INT)], $aParams);
+        if($sUnitView)
+            $aParams['unit_view'] = $sUnitView;
+
+        return $this->_serviceBrowse($sType, $aParams, BX_DB_PADDING_DEF, $bEmptyMessage, $bAjaxPaginate);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-browsing Browsing
+     * @subsubsection bx_base_general-browse_favorite_list_actions browse_favorite_list_actions
+     * 
+     * @code bx_srv('bx_posts', 'browse_favorite_list_actions', [...]); @endcode
+     * 
+     * Display menu actions for favorite lists
+     * 
+     * @see BxBaseModGeneralModule::serviceFavoritesListActions
+     */
+    /** 
+     * @ref bx_base_general-browse_favorite_list_actions "browse_favorite_list_actions"
+     */
+    public function serviceFavoritesListActions()
+    {
+        $iListId = 0;
+        if(bx_get('list_id') !== false)
+            $iListId = (int) bx_get('list_id');        
+
+        $oProfile = null;
+        if(bx_get('profile_id') !== false)
+            $oProfile = BxDolProfile:: getInstance(bx_process_input(bx_get('profile_id'), BX_DATA_INT));
+        if(!$oProfile)
+            return false;
+
+        $CNF = &$this->_oConfig->CNF;
+        $oFavorite = BxDolFavorite::getObjectInstance($CNF['OBJECT_FAVORITES'], 0, true);
+
+        $aList = $oFavorite->getQueryObject()->getList(array('type' => 'id', 'list_id' => $iListId));
+        if($iListId != 0 && (empty($aList) || !is_array($aList)))
+            return false;
+
+        $sRv = '';
+        if (!empty($aList) && $oFavorite->isAllowedEditList($aList['author_id'])){  
+            $aMarkers = array(
+                'js_object' => $oFavorite->getJsObjectName(),
+                'list_id' => $iListId,
+            );
+
+            $oMenu = BxDolMenu::getObjectInstance('sys_favorite_list');
+
+            $oMenu->addMarkers($aMarkers);
+            $sMenu = $oMenu->getCode();
+
+            $sRv .= $sMenu . $oFavorite->getJsScript();
+        }
+
+        $aMarkers = array(
+            'id' => $iListId,
+            'module' => $this->_aModule['name'],
+            'url' => $this->_getFavoriteListUrl($iListId, $oProfile->id()),
+            'title' => $iListId > 0 ? $aList['title'] : _t('_sys_txt_default_favorite_list')
+        );
+
+        $oMenu = BxDolMenu::getObjectInstance('sys_social_sharing');
+        $oMenu->addMarkers($aMarkers);
+        $sRv .= $sMenu = $oMenu->getCode();
+        return $sRv;
+    }
+     
+     /**
+    * @page service Service Calls
+    * @section bx_base_general Base General
+    * @subsection bx_base_general-menu Menu
+    * @subsubsection bx_base_general-favorites_list_info favorites_list_info
+    * 
+    * @code bx_srv('bx_posts', 'favorites_list_info', [...]); @endcode
+    * 
+    * Favorites list info
+    * @param $aParams array with additional custom params 
+    *                 which may overwrite some default values
+    * 
+    * @see BxBaseModGeneralModule::serviceFavoritesListInfo
+    */
+    /** 
+    * @ref bx_base_general-favorites_list_info "favorites_list_info"
+    */
+    public function serviceFavoritesListInfo($aParams = array())
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $iListId = 0;
+        if(bx_get('list_id') !== false)
+            $iListId = (int) bx_get('list_id');       
+
+        $oFavorite = BxDolFavorite::getObjectInstance($CNF['OBJECT_FAVORITES'], 0, true);
+        $aList = $oFavorite->getQueryObject()->getList(array('type' => 'id', 'list_id' => $iListId));
+        if($iListId != 0 && (empty($aList) || !is_array($aList)))
+            return false;
+
+        $oProfile = null;
+        if(bx_get('profile_id') !== false)
+            $oProfile = BxDolProfile:: getInstance(bx_process_input(bx_get('profile_id'), BX_DATA_INT));
+
+        if(!$oProfile)
+            return false;
+
+        return $this->_oTemplate->getFavoritesListInfo($aList, $oProfile);
+    }
+    
+    /**
+     * Display entries posted into particular context
+     * @return HTML string
+     */
+    public function serviceBrowseContext ($iProfileId = 0, $aParams = array())
+    {
+        return $this->_serviceBrowseWithParam ('context', 'profile_id', $iProfileId, $aParams);
+    }
+    
+    public function _serviceBrowseWithParam ($sParamName, $sParamGet, $sParamVal, $aParams = array())
+    {
+        if(!$sParamVal)
+            $sParamVal = bx_process_input(bx_get($sParamGet), BX_DATA_INT);
+        if(!$sParamVal)
+            return '';
+
+        $bEmptyMessage = true;
+        if(isset($aParams['empty_message'])) {
+            $bEmptyMessage = (bool)$aParams['empty_message'];
+            unset($aParams['empty_message']);
+        }
+
+        $bAjaxPaginate = true;
+        if(isset($aParams['ajax_paginate'])) {
+            $bAjaxPaginate = (bool)$aParams['ajax_paginate'];
+            unset($aParams['ajax_paginate']);
+        }
+
+        return $this->_serviceBrowse ($sParamName, array_merge(array($sParamName => $sParamVal), $aParams), BX_DB_PADDING_DEF, $bEmptyMessage, $bAjaxPaginate);
+    }
+
+    public function getFormsHelper ()
+    {
+        $sClass = 'FormsEntryHelper';
+        $oObject = null;
+
+        bx_alert('system', 'get_forms_helper', 0, 0, [
+            'class' => &$sClass,
+            'object' => &$oObject
+        ]);
+
+        if($oObject !== null) 
+            return $oObject;
+
+        bx_import($sClass, $this->_aModule);
+        $sClass = $this->_aModule['class_prefix'] . $sClass;
+        return new $sClass($this);
+    }
+
+    public function serviceFormsHelper ()
+    {
+        return $this->getFormsHelper();
+    }
+
+	/**
+     * Add entry using provided fields' values.
+     * @return array with result: 'code' is 0 on success or non-zero on error, 'message' is error message in case of error, 'content' is content info array in case of success
+     */
+    public function serviceEntityAdd ($iProfile, $aValues, $sDisplay = false)
+    {
+        $oFormsHelper = $this->getFormsHelper();
+        return $oFormsHelper->addData($iProfile, $aValues, $sDisplay);
+    }
+
+	/**
+     * Perform redirect after content creation
+     * @return nothing, rediret header is sent
+     */    
+    public function serviceRedirectAfterAdd($aContentInfo)
+    {
+        $oFormsHelper = $this->getFormsHelper();
+        $oFormsHelper->redirectAfterAdd($aContentInfo);
+    }
+
+    /**
+     * Get form object for add, edit, view or delete the content 
+     * @param $sType 'add', 'edit', 'view' or 'delete'
+     * @param $aParams optional array with parameters(display name, etc)
+     * @return form object or false on error
+     */
+    public function serviceGetObjectForm ($sType, $aParams = array())
+    {
+        if(!in_array($sType, array('add', 'edit', 'view', 'delete')))
+            return false;
+
+        $iLoggedId = (int)bx_get_logged_profile_id();
+        $mixedContextId = isset($aParams['context_id']) ? $aParams['context_id'] : false;
+
+        $oContextProfile = false;
+        $bContext = $mixedContextId !== false;
+        if($bContext) {
+            $oContextProfile = BxDolProfile::getInstance(abs($mixedContextId));
+            $bContext = $oContextProfile !== false;
+
+            if($bContext && $oContextProfile->checkAllowedPostInProfile() !== CHECK_ACTION_RESULT_ALLOWED)
+                return false;
+        }
+
+        $CNF = &$this->_oConfig->CNF;
+        $oFormsHelper = $this->getFormsHelper();
+
+        $sParamsKey = 'ajax_mode';
+        if(isset($aParams[$sParamsKey]) && (bool)$aParams[$sParamsKey] === true)
+            $oFormsHelper->setAjaxMode((bool)$aParams[$sParamsKey]);
+
+        $sParamsKey = 'absolute_action_url';
+        if(isset($aParams[$sParamsKey]) && (bool)$aParams[$sParamsKey] === true)
+            $oFormsHelper->setAbsoluteActionUrl((bool)$aParams[$sParamsKey]);
+
+        if($bContext)
+            $oFormsHelper->setContextId($mixedContextId);
+
+        $sDisplay = !empty($aParams['display']) ? $aParams['display'] : false;
+
+        $sFunc = 'getObjectForm' . ucfirst($sType);
+        $oForm = $oFormsHelper->$sFunc($sDisplay);
+
+        $sKey = 'FIELD_ALLOW_VIEW_TO';
+        if(!empty($CNF[$sKey]) && !empty($oForm->aInputs[$CNF[$sKey]]) && (!$bContext || $mixedContextId < 0)) {
+            $bContextOwner = $bContext && abs($mixedContextId) == $iLoggedId;
+
+            if(!$bContext || $bContextOwner) {
+                $iGc = 0;
+                $iKeyGh = false;
+                foreach($oForm->aInputs[$CNF[$sKey]]['values'] as $iKey => $aValue) {
+                    if(isset($aValue['type']) && in_array($aValue['type'], array('group_header', 'group_end'))) {
+                        if($iKeyGh !== false && $iGc == 0) {
+                            unset($oForm->aInputs[$CNF[$sKey]]['values'][$iKeyGh]);
+                            $iKeyGh = false;
+
+                            if($aValue['type'] == 'group_end')
+                                unset($oForm->aInputs[$CNF[$sKey]]['values'][$iKey]);
+                        }
+
+                        if($aValue['type'] == 'group_header') {
+                            $iGc = 0;
+                            $iKeyGh = $iKey;
+                        }
+
+                        continue;
+                    }
+
+                    //--- Show 'Public' privacy group only in Public post form. 
+                    if(!$bContext && $aValue['key'] == BX_DOL_PG_ALL) {
+                        $iGc += 1;
+                        continue;
+                    }
+
+                    //--- Show a default privacy groups in Profile (for Owner) post form.
+                    if($bContextOwner && ((int)$aValue['key'] >= 0 || $mixedContextId == $aValue['key'])) {
+                        $iGc += 1;
+                        continue;
+                    }
+
+                    unset($oForm->aInputs[$CNF[$sKey]]['values'][$iKey]);
+                }
+            }
+            else {
+                $oForm->aInputs[$CNF[$sKey]]['value'] = $mixedContextId;
+                $oForm->aInputs[$CNF[$sKey]]['type'] = 'hidden';
+            }
+        }
+
+        $sKey = 'FIELD_LABELS';
+        if(!empty($CNF[$sKey]) && !empty($oForm->aInputs[$CNF[$sKey]]) && empty($oForm->aInputs[$CNF[$sKey]]['value']) && $bContext) {
+            $iContextProfileId = $oContextProfile->id();
+            $sContextModule = $oContextProfile->getModule();
+            
+            if(bx_srv($sContextModule, 'is_fan', [$iContextProfileId, $iLoggedId])) {
+                $aContextInfo = bx_srv($sContextModule, 'get_content_info_by_profile_id', [$iContextProfileId]);
+                if(!empty($aContextInfo['hashtag']))
+                    $oForm->aInputs[$CNF[$sKey]]['value'] = [$aContextInfo['hashtag']];
+            }
+        }
+
+        /**
+         * @hooks
+         * @hookdef hook-system-get_object_form 'system', 'get_object_form' - hook to override an object of create content form, @see BxDolForm
+         * - $unit_name - equals `system`
+         * - $action - equals `get_object_form`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `module` - [string] module name
+         *      - `type` - [string] 'add', 'edit', 'view' or 'delete' form display type
+         *      - `params` - [array] form params array as key&value pairs
+         *      - `form` - [object] by ref, an object of create content form, @see BxDolForm, can be overridden in hook processing
+         * @hook @ref hook-system-get_object_form
+         */
+        bx_alert('system', 'get_object_form', 0, 0, [
+            'module' => $this->_oConfig->getName(),
+            'type' => $sType,
+            'params' => $aParams,
+            'form' => &$oForm
+        ]);
+
+        return $oForm;
+    }
+
+    /**
+     *
+     *
+     * Create entry form
+     * @return HTML string
+     */
+    public function serviceEntityCreate ($sParams = false)
+    {
+        if($this->_bIsApi && ($aParams = bx_api_get_browse_params($sParams))) {
+            if(($aKeys = ['context_pid', 'context_nid', 'context_usage']) && ($aParamsContext = array_intersect_key($aParams, array_flip($aKeys)))) {
+                $_GET = array_merge($_GET, $aParamsContext);
+                $sParams = array_diff_key($aParams, array_flip($aKeys));
+            }
+        }
+
+        $bParamsArray = is_array($sParams);
+
+        $sDisplay = is_string($sParams) ? $sParams : false;
+        if($bParamsArray && !empty($sParams['display']))
+            $sDisplay = $sParams['display'];
+
+        $oFormsHelper = $this->getFormsHelper();
+        if($bParamsArray && isset($sParams['dynamic_mode']))
+            $oFormsHelper->setDynamicMode($sParams['dynamic_mode']);
+
+        $mixedResult = $oFormsHelper->addDataForm($sDisplay);
+        if($this->_bIsApi)
+            return $mixedResult;
+        
+        if(isset($mixedResult['_dt']) && $mixedResult['_dt'] == 'json') {
+            echoJson($mixedResult);
+            exit;
+        }
+
+        return $mixedResult;
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-forms Forms
+     * @subsubsection bx_base_general-get_create_post_form get_create_post_form
+     * 
+     * @code bx_srv('bx_posts', 'get_create_post_form'); @endcode
+     * 
+     * Get content creation form
+     * @param $aParams additional parameters array: 
+     *                 context_id, ajax_mode, absolute_action_url, displays
+     * 
+     * @see BxBaseModGeneralModule::serviceGetCreatePostForm
+     */
+    /** 
+     * @ref bx_base_general-get_create_post_form "get_create_post_form"
+     */
+    public function serviceGetCreatePostForm($aParams = [])
+    {
+    	$aParams = array_merge($this->_aFormParams, $aParams);
+
+        if($aParams['context_id'] !== false && ($aContextInfo = BxDolProfileQuery::getInstance()->getInfoById(abs($aParams['context_id']))))
+            if(bx_srv($aContextInfo['type'], 'check_allowed_post_in_profile', [$aContextInfo['content_id'], $this->getName()]) !== CHECK_ACTION_RESULT_ALLOWED)
+                return '';
+
+    	$oForm = $this->serviceGetObjectForm('add', $aParams);
+    	if(!$oForm)
+            return '';
+
+    	return $this->serviceEntityCreate($aParams);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-forms Forms
+     * @subsubsection bx_base_general-entity_edit entity_edit
+     * 
+     * @code bx_srv('bx_posts', 'entity_edit', [...]); @endcode
+     * 
+     * Get content edit form
+     * @param $iContentId content ID
+     * @param $sDisplay optional form display name
+     * 
+     * @see BxBaseModGeneralModule::serviceEntityEdit
+     */
+    /** 
+     * @ref bx_base_general-entity_edit "entity_edit"
+     */
+    public function serviceEntityEdit ($iContentId = 0, $sDisplay = false)
+    {
+        return $this->_serviceEntityForm ('editDataForm', $iContentId, $sDisplay);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-forms Forms
+     * @subsubsection bx_base_general-entity_delete entity_delete
+     * 
+     * @code bx_srv('bx_posts', 'entity_delete', [...]); @endcode
+     * 
+     * Get content delete form
+     * @param $iContentId content ID
+     * 
+     * @see BxBaseModGeneralModule::serviceEntityDelete
+     */
+    /** 
+     * @ref bx_base_general-entity_delete "entity_delete"
+     */
+    public function serviceEntityDelete ($iContentId = 0)
+    {
+        $mixedResult = $this->_serviceEntityForm ('deleteDataForm', $iContentId);
+        if(!bx_is_api()) 
+            return $mixedResult;
+
+        $aResult = [];
+        if(is_a($mixedResult, 'BxTemplFormView')){
+            $iContentId = $this->_getContent($iContentId, false);
+            $aResult = bx_api_get_block('form', $mixedResult->getCodeAPI(), ['ext' => ['name' => $this->getName(), 'request' => ['url' => '/api.php?r=' . $this->_aModule['name'] . '/entity_delete&params[]=' . $iContentId . '&params[]=' . $mixedResult->aParams['display'], 'immutable' => true]]]);
+        }
+        else
+            $aResult = $mixedResult;
+
+        return [
+            $aResult
+        ];
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-page_blocks Page Blocks
+     * @subsubsection bx_base_general-entity_text_block entity_text_block
+     * 
+     * @code bx_srv('bx_posts', 'entity_text_block', [...]); @endcode
+     * 
+     * Get content text/description
+     * @param $iContentId content ID
+     * 
+     * @see BxBaseModGeneralModule::serviceEntityTextBlock
+     */
+    /** 
+     * @ref bx_base_general-entity_text_block "entity_text_block"
+     */
+    public function serviceEntityTextBlock ($iContentId = 0)
+    {
+        return $this->_serviceEntityForm ('viewDataEntry', $iContentId);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-page_blocks Page Blocks
+     * @subsubsection bx_base_general-entity_info entity_info
+     * 
+     * @code bx_srv('bx_posts', 'entity_info', [...]); @endcode
+     * 
+     * Get content info
+     * @param $iContentId content ID
+     * 
+     * @see BxBaseModGeneralModule::serviceEntityInfo
+     */
+    /** 
+     * @ref bx_base_general-entity_info "entity_info"
+     */
+    public function serviceEntityInfo ($iContentId = 0, $sDisplay = false)
+    {
+        return $this->_serviceEntityForm ('viewDataForm', $iContentId, $sDisplay);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-page_blocks Page Blocks
+     * @subsubsection bx_base_general-entity_info_full entity_info_full
+     * 
+     * @code bx_srv('bx_posts', 'entity_info_full', [...]); @endcode
+     * 
+     * Get full content info
+     * @param $iContentId content ID
+     * 
+     * @see BxBaseModGeneralModule::serviceEntityInfoFull
+     */
+    /** 
+     * @ref bx_base_general-entity_info_full "entity_info_full"
+     */
+	public function serviceEntityInfoFull ($iContentId = 0)
+    {
+    	$CNF = &$this->_oConfig->CNF;
+
+    	$sDisplayName = 'OBJECT_FORM_ENTRY_DISPLAY_VIEW_FULL';
+        return $this->_serviceEntityForm ('viewDataForm', $iContentId, !empty($CNF[$sDisplayName]) ? $CNF[$sDisplayName] : false);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-page_blocks Page Blocks
+     * @subsubsection bx_base_general-entity_info_extended entity_info_extended
+     * 
+     * @code bx_srv('bx_posts', 'entity_info_extended', [...]); @endcode
+     * 
+     * Get extended content info
+     * @param $iContentId content ID
+     * 
+     * @see BxBaseModGeneralModule::serviceEntityInfoExtended
+     */
+    /** 
+     * @ref bx_base_general-entity_info_extended "entity_info_extended"
+     */
+	public function serviceEntityInfoExtended ($iContentId = 0)
+    {
+        return $this->_serviceTemplateFunc ('entryInfo', $iContentId);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-page_blocks Page Blocks
+     * @subsubsection bx_base_general-entity_location entity_location
+     * 
+     * @code bx_srv('bx_posts', 'entity_location', [...]); @endcode
+     * 
+     * Get content location
+     * @param $iContentId content ID
+     * 
+     * @see BxBaseModGeneralModule::serviceEntityLocation
+     */
+    /** 
+     * @ref bx_base_general-entity_location "entity_location"
+     */
+    public function serviceEntityLocation ($iContentId = 0)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $mixedContent = $this->_getContent($iContentId);
+        if($mixedContent === false)
+            return false;
+
+        list($iContentId, $aContentInfo) = $mixedContent;
+
+        $oFormsHelper = $this->getFormsHelper();
+        $oFormView = $oFormsHelper->getObjectFormView();
+        if(!$oFormView)
+            return false;
+        
+        $oFormView->initChecker($aContentInfo);
+        if(empty($CNF['FIELD_LOCATION']) || !$oFormView->isInputVisible($CNF['FIELD_LOCATION']))
+            return false;
+
+        return $this->_oTemplate->entryLocation ($iContentId);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-page_blocks Page Blocks
+     * @subsubsection bx_base_general-entity_comments entity_comments
+     * 
+     * @code bx_srv('bx_posts', 'entity_comments', [...]); @endcode
+     * 
+     * Get content comments
+     * @param $iContentId content ID
+     * 
+     * @see BxBaseModGeneralModule::serviceEntityComments
+     */
+    /** 
+     * @ref bx_base_general-entity_comments "entity_comments"
+     */
+    public function serviceEntityComments ($iContentId = 0)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        if(empty($CNF['OBJECT_COMMENTS']))
+            return '';
+
+        return $this->_entityComments($CNF['OBJECT_COMMENTS'], $iContentId);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-page_blocks Page Blocks
+     * @subsubsection bx_base_general-entity_attachments entity_attachments
+     * 
+     * @code bx_srv('bx_posts', 'entity_attachments', [...]); @endcode
+     * 
+     * Get content attachments
+     * @param $iContentId content ID
+     * 
+     * @see BxBaseModGeneralModule::serviceEntityAttachments
+     */
+    /** 
+     * @ref bx_base_general-entity_attachments "entity_attachments"
+     */
+    public function serviceEntityAttachments ($iContentId = 0)
+    {
+        return $this->_serviceTemplateFunc ('entryAttachments', $iContentId);
+    }
+    
+    /**
+     * Delete content entry
+     * @param $iContentId content id 
+     * @return error message or empty string on success
+     */
+    public function serviceDeleteEntity ($iContentId, $sFuncDelete = 'deleteData')
+    {
+        $oFormsHelper = $this->getFormsHelper();
+        return $oFormsHelper->$sFuncDelete($iContentId);
+    }
+
+    /**
+     * Edit content entry
+     * @param $iContentId content id 
+     * @param $aValues key value pairs to update
+     * @return error message or empty string on success
+     */
+    public function serviceEditEntity ($iContentId, $aValues)
+    {
+        $oFormsHelper = $this->getFormsHelper();
+        return $oFormsHelper->editData($iContentId, $aValues);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-menu Menu
+     * @subsubsection bx_base_general-my_entries_actions my_entries_actions
+     * 
+     * @code bx_srv('bx_posts', 'my_entries_actions', [...]); @endcode
+     * 
+     * My entries actions menu
+     * @param $iProfileId profiles ID, 
+     *        if omitted it try to get it from 'profile_id' GET param
+     * 
+     * @see BxBaseModGeneralModule::serviceMyEntriesActions
+     */
+    /** 
+     * @ref bx_base_general-my_entries_actions "my_entries_actions"
+     */
+    public function serviceMyEntriesActions ($iProfileId = 0)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        if(empty($CNF['OBJECT_MENU_ACTIONS_MY_ENTRIES']))
+            return false;
+
+        if (!$iProfileId)
+            $iProfileId = bx_process_input(bx_get('profile_id'), BX_DATA_INT);
+        if (!$iProfileId || !($oProfile = BxDolProfile::getInstance($iProfileId)))
+            return false;
+
+        if ($iProfileId != $this->_iProfileId)
+            return false;
+
+        $oMenu = BxTemplMenu::getObjectInstance($CNF['OBJECT_MENU_ACTIONS_MY_ENTRIES']);
+        return $oMenu ? $oMenu->getCode() : false;
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-menu Menu
+     * @subsubsection bx_base_general-entity_all_actions entity_all_actions
+     * 
+     * @code bx_srv('bx_posts', 'entity_all_actions', [...]); @endcode
+     * 
+     * Entry actions and social sharing actions
+     * @param $mixedContent content ID
+     * @param $aParams additional params
+     * 
+     * @see BxBaseModGeneralModule::serviceEntityAllActions
+     */
+    /** 
+     * @ref bx_base_general-entity_all_actions "entity_all_actions"
+     */
+    public function serviceEntityAllActions ($mixedContent = false, $aParams = array())
+    {
+        $mixedResult = $this->getEntryAllActions($mixedContent, $aParams);
+
+        if(bx_is_api())
+            return [bx_api_get_block('entity_actions', $mixedResult)];
+        else
+            return $this->_oTemplate->entryAllActions($mixedResult);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-menu Menu
+     * @subsubsection bx_base_general-entity_actions entity_actions
+     * 
+     * @code bx_srv('bx_posts', 'entity_actions', [...]); @endcode
+     * 
+     * Entry actions, without social sharing actions
+     * @param $iContentId content ID
+     * 
+     * @see BxBaseModGeneralModule::serviceEntityActions
+     */
+    /** 
+     * @ref bx_base_general-entity_actions "entity_actions"
+     */
+    public function serviceEntityActions ($iContentId = 0)
+    {
+        $iContentId = $this->_getContent($iContentId, false);
+        if($iContentId === false)
+            return false;
+
+        $oMenu = BxTemplMenu::getObjectInstance($this->_oConfig->CNF['OBJECT_MENU_ACTIONS_VIEW_ENTRY']);
+        return $oMenu ? $oMenu->getCode() : false;
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-page_blocks Page Blocks
+     * @subsubsection bx_base_general-entity_reports entity_reports
+     * 
+     * @code bx_srv('bx_posts', 'entity_reports', [...]); @endcode
+     * 
+     * Get content reports
+     * @param $iContentId content ID
+     * 
+     * @see BxBaseModGeneralModule::serviceEntityReports
+     */
+    /** 
+     * @ref bx_base_general-entity_reports "entity_reports"
+     */
+    public function serviceEntityReports ($iContentId = 0)
+    {
+        $mixedContent = $this->_getContent($iContentId);
+        list($iContentId, $aContentInfo) = $mixedContent;
+        
+        if($iContentId === false)
+            return false;
+        
+        $CNF = &$this->_oConfig->CNF;
+        
+        if (!isset($CNF['OBJECT_REPORTS']) || !isset($CNF['OBJECT_NOTES']))
+            return false;
+
+        if(!$aContentInfo || !(BxDolAcl::getInstance()->isMemberLevelInSet(192) || (isset($CNF['FIELD_AUTHOR']) && bx_get_logged_profile_id() == $aContentInfo[$CNF['FIELD_AUTHOR']])))
+            return false;
+
+        $oReport = BxDolReport::getObjectInstance($CNF['OBJECT_REPORTS'], $iContentId, true);
+        if(!$oReport)
+            return false;
+
+        return $oReport->getReportedByWithComments($CNF['OBJECT_NOTES']);
+    }
+    
+     /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-page_blocks Page Blocks
+     * @subsubsection bx_base_general-reports_count_by_status reports_count_by_status
+     * 
+     * @code bx_srv('bx_posts', 'reports_count_by_status', [...]); @endcode
+     * 
+     * Get number of reports with specified status
+     * @param $iStatus status ID
+     * 
+     * @see BxBaseModGeneralModule::serviceReportsCountByStatus
+     */
+    /** 
+     * @ref bx_base_general-entity_reports "entity_reports"
+     */
+    public function serviceReportsCountByStatus ($iStatus)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        
+        if (!isset($CNF['OBJECT_REPORTS']) || !isset($CNF['OBJECT_NOTES']))
+            return false;
+        
+        $oReport = BxDolReport::getObjectInstance($CNF['OBJECT_REPORTS'], 0, false);
+        return $oReport->getCountByStatus($iStatus);
+    }
+    
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-menu Menu
+     * @subsubsection bx_base_general-entity_social_sharing entity_social_sharing
+     * 
+     * @code bx_srv('bx_posts', 'entity_social_sharing', [...]); @endcode
+     * 
+     * Entry social sharing actions
+     * @param $mixedContent content ID or array with integer content ID and 
+     *                      array with content info, or false
+     * @param $aParams array with additional custom params 
+     *                 which may overwrite some default values
+     * 
+     * @see BxBaseModGeneralModule::serviceEntitySocialSharing
+     */
+    /** 
+     * @ref bx_base_general-entity_social_sharing "entity_social_sharing"
+     */
+    public function serviceEntitySocialSharing($mixedContent = false, $aParams = array())
+    {
+        if(!empty($mixedContent)) {
+            if(!is_array($mixedContent))
+               $mixedContent = array((int)$mixedContent, array());
+        }
+        else {
+            $mixedContent = $this->_getContent();
+            if($mixedContent === false)
+                return false;
+        }
+
+        list($iContentId, $aContentInfo) = $mixedContent;
+
+        $CNF = &$this->_oConfig->CNF;
+
+        $sUri = !empty($aParams['uri']) ? $aParams['uri'] : '';
+        if(empty($sUri) && !empty($CNF['URI_VIEW_ENTRY']))
+            $sUri = $CNF['URI_VIEW_ENTRY'];
+
+        $sUrl = !empty($sUri) ? BxDolPermalinks::getInstance()->permalink('page.php?i=' . $sUri . '&id=' . $iContentId) : '';
+
+        $sTitle = !empty($aParams['title']) ? $aParams['title'] : '';
+        if(empty($sTitle) && !empty($aContentInfo[$CNF['FIELD_TITLE']]))
+            $sTitle = $aContentInfo[$CNF['FIELD_TITLE']];
+
+        $aMarkers = array(
+            'id' => $iContentId,
+            'module' => $this->_aModule['name'],
+            'url' => bx_absolute_url($sUrl),
+            'title' => $sTitle,
+        );
+
+        $iIdThumb = !empty($aParams['id_thumb']) ? (int)$aParams['id_thumb'] : 0;
+        if(empty($iIdThumb) && !empty($CNF['FIELD_THUMB']) && !empty($aContentInfo[$CNF['FIELD_THUMB']]))
+            $iIdThumb = (int)$aContentInfo[$CNF['FIELD_THUMB']];
+
+        if ($iIdThumb) {
+            $sTranscoder = !empty($aParams['object_transcoder']) ? $aParams['object_transcoder'] : '';
+            $sStorage = !empty($aParams['object_storage']) ? $aParams['object_storage'] : '';
+            if(empty($sStorage) && !empty($CNF['OBJECT_STORAGE']))
+                $sStorage = $CNF['OBJECT_STORAGE'];
+
+            if(!empty($sTranscoder))
+                $o = BxDolTranscoder::getObjectInstance($sTranscoder);
+            else if(!empty($sStorage))
+                $o = BxDolStorage::getObjectInstance($sStorage);
+
+            $sImgUrl = $o ? $o->getFileUrlById($iIdThumb) : '';
+            if($sImgUrl)
+                $aMarkers['img_url'] = $sImgUrl;
+        }
+
+        $oMenu = BxDolMenu::getObjectInstance('sys_social_sharing');
+        $oMenu->addMarkers($aMarkers);
+        $sMenu = $oMenu->getCode();
+
+        if(empty($sMenu))
+            return '';
+
+        return $this->_oTemplate->parseHtmlByName('entry-share.html', array(
+            'menu' => $sMenu
+        ));
+    }
+    
+    /**
+     * Entry context block
+     */
+    public function serviceEntityContext ($iContentId = 0)
+    {
+        return $this->_serviceTemplateFunc ('entryContext', $iContentId);
+    }
+
+    /**
+     * Blocks for lables tree browsing
+     */
+    public function serviceGetLablesTree()
+    {
+		$aBreadcrumbs = array();
+		$this->_getLablesBreadcrumbsArray(bx_get('label'), $aBreadcrumbs);
+		return $this->_getLablesTreeLevel($aBreadcrumbs);
+	}	
+    
+    public function serviceGetLablesBreadcrumbs()
+    {
+		if (bx_get('label')){
+            $aBreadcrumbs = array();
+            $this->_getLablesBreadcrumbsArray(bx_process_input(bx_get('label'), BX_DATA_INT), $aBreadcrumbs);
+            $aLabels = array();
+            foreach (array_reverse($aBreadcrumbs) as $aLabel){
+                $aLabels[] = array(
+                   'value' => $aLabel['value'], 
+                   'url' => $this->_getLablesBrowseUrl($aLabel['id'])
+               );
+            }
+            
+            return $this->_oTemplate->parseHtmlByName('labels_breadcrumbs.html', array('bx_repeat:items' => $aLabels));
+		}
+        return '';
+	}
+    
+    public function serviceBrowseByLabel()
+    {   
+        $CNF = &$this->_oConfig->CNF;
+        if (empty($CNF['OBJECT_METATAGS']))
+            return '';
+        
+		$sMode = 'recent';
+		if(bx_srv('system', 'is_module_content', [$this->_aModule['name']]))
+		   $sMode = 'public';
+		   
+		$sClassSearchResult ='SearchResult';
+		
+		bx_import($sClassSearchResult, $this->_aModule['name']);
+        $sClass = $this->_aModule['class_prefix'] . $sClassSearchResult;
+		
+        $o = new $sClass($sMode, false);
+		$o->setDesignBoxTemplateId(BX_DB_PADDING_DEF);
+        $o->setDisplayEmptyMsg(true);
+        $o->setAjaxPaginate(false);
+        $o->setUnitParams(array('context' => $sMode));
+		
+		if (bx_get('label')){
+			$iLabelId = (int)bx_get('label');
+			$oLabel = BxDolLabel::getInstance();
+			$aLabel = $oLabel->getLabels(array('type' => 'id', 'id' => $iLabelId));
+			$oMetatags = BxDolMetatags::getObjectInstance($CNF['OBJECT_METATAGS']);
+			$sTmp =$oMetatags->keywordsSetSearchCondition($o, $aLabel['value']);
+		}
+		
+        if ($o->isError)
+            return '';
+
+        if ($s = $o->processing())
+            return $s;
+        else
+            return '';
+	}
+    
+    public function serviceBrowseByCategories($sUnitView, $bEmptyMessage, $bAjaxPaginate, $sMode, $iPerPage)
+    {   
+        $CNF = &$this->_oConfig->CNF;
+        $sClassSearchResult ='SearchResult';
+		
+		bx_import($sClassSearchResult, $this->_aModule['name']);
+        $sClass = $this->_aModule['class_prefix'] . $sClassSearchResult;
+        $o = new $sClass($sMode, array('unit_view' => $sUnitView, 'paginate' => array('perPage' => 10, 'start' => 0, 'num' => 11)));
+		$o->setDesignBoxTemplateId(BX_DB_PADDING_DEF);
+        $o->setAjaxPaginate($bAjaxPaginate);
+        $o->setCategoryObject('multi');
+        
+        $aCategoriesOutput = array();
+		$aCategories = BxDolCategories::getInstance()->getData(array('type' => 'by_module_with_num', 'module' => $this->_aModule['name']));
+        
+        foreach($aCategories as $aCategory){
+            if ($aCategory['num'] > 0){
+                $o->setCustomSearchCondition(array('keyword' => $aCategory['value']));
+                $o->setPaginatePerPage(2);
+                if (!$o->isError){
+                    $aResult = $o->processing();
+                    if ($aResult && $aResult['content'] != '')
+                        $aCategoriesOutput[] =  array('name' => _t($aCategory['value']), 'url' => '', 'content' => $aResult['content']);
+                }
+            }
+        }
+
+        return $this->_oTemplate->parseHtmlByName('browse_by_categories.html', array(
+            'bx_repeat:categories' => $aCategoriesOutput,
+        ));
+
+	}
+    
+    /**
+     * Data for Notifications module
+     */
+    public function serviceGetNotificationsData()
+    {
+    	$sModule = $this->_aModule['name'];
+
+    	$sEventPrivacy = $sModule . '_allow_view_event_to';
+        if(BxDolPrivacy::getObjectInstance($sEventPrivacy) === false)
+            $sEventPrivacy = '';
+
+        $aResult = [
+            'handlers' => [
+                ['group' => $sModule . '_object', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'added', 'module_name' => $sModule, 'module_method' => 'get_notifications_post', 'module_class' => 'Module', 'module_event_privacy' => $sEventPrivacy],
+                ['group' => $sModule . '_object', 'type' => 'update', 'alert_unit' => $sModule, 'alert_action' => 'edited'],
+                ['group' => $sModule . '_object', 'type' => 'delete', 'alert_unit' => $sModule, 'alert_action' => 'deleted'],
+
+                ['group' => $sModule . '_object_approved', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'approved', 'module_name' => $sModule, 'module_method' => 'get_notifications_post_approved', 'module_class' => 'Module', 'module_event_privacy' => $sEventPrivacy],
+
+                ['group' => $sModule . '_comment', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'commentPost', 'module_name' => $sModule, 'module_method' => 'get_notifications_comment', 'module_class' => 'Module', 'module_event_privacy' => $sEventPrivacy],
+                ['group' => $sModule . '_comment', 'type' => 'delete', 'alert_unit' => $sModule, 'alert_action' => 'commentRemoved'],
+
+                ['group' => $sModule . '_reply', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'replyPost', 'module_name' => $sModule, 'module_method' => 'get_notifications_reply', 'module_class' => 'Module', 'module_event_privacy' => $sEventPrivacy],
+                ['group' => $sModule . '_reply', 'type' => 'delete', 'alert_unit' => $sModule, 'alert_action' => 'replyRemoved'],
+
+                ['group' => $sModule . '_vote', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'doVote', 'module_name' => $sModule, 'module_method' => 'get_notifications_vote', 'module_class' => 'Module', 'module_event_privacy' => $sEventPrivacy],
+                ['group' => $sModule . '_vote', 'type' => 'delete', 'alert_unit' => $sModule, 'alert_action' => 'undoVote'],
+
+                ['group' => $sModule . '_reaction', 'type' => 'insert', 'alert_unit' => $sModule . '_reactions', 'alert_action' => 'doVote', 'module_name' => $sModule, 'module_method' => 'get_notifications_reaction', 'module_class' => 'Module', 'module_event_privacy' => $sEventPrivacy],
+                ['group' => $sModule . '_reaction', 'type' => 'delete', 'alert_unit' => $sModule . '_reactions', 'alert_action' => 'undoVote'],
+
+                ['group' => $sModule . '_score_up', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'doVoteUp', 'module_name' => $sModule, 'module_method' => 'get_notifications_score_up', 'module_class' => 'Module', 'module_event_privacy' => $sEventPrivacy],
+
+                ['group' => $sModule . '_score_down', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'doVoteDown', 'module_name' => $sModule, 'module_method' => 'get_notifications_score_down', 'module_class' => 'Module', 'module_event_privacy' => $sEventPrivacy],
+
+                //--- Moderation related: For 'admins'.
+                ['group' => $sModule . '_object_pending_approval', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'pending_approval', 'module_name' => $sModule, 'module_method' => 'get_notifications_post_pending_approval', 'module_class' => 'Module', 'module_event_privacy' => $sEventPrivacy],
+            ],
+            'settings' => [
+                ['group' => 'content', 'unit' => $sModule, 'action' => 'added', 'types' => ['follow_member', 'follow_context']],
+                ['group' => 'content_updated', 'unit' => $sModule, 'action' => 'approved', 'types' => ['personal']],
+                ['group' => 'comment', 'unit' => $sModule, 'action' => 'commentPost', 'types' => ['personal', 'follow_member', 'follow_context']],
+                ['group' => 'reply', 'unit' => $sModule, 'action' => 'replyPost', 'types' => ['personal']],
+                ['group' => 'vote', 'unit' => $sModule, 'action' => 'doVote', 'types' => ['personal', 'follow_member', 'follow_context']],
+                ['group' => 'vote', 'unit' => $sModule . '_reactions', 'action' => 'doVote', 'types' => ['personal', 'follow_member', 'follow_context']],
+                ['group' => 'score_up', 'unit' => $sModule, 'action' => 'doVoteUp', 'types' => ['personal', 'follow_member', 'follow_context']],
+                ['group' => 'score_down', 'unit' => $sModule, 'action' => 'doVoteDown', 'types' => ['personal', 'follow_member', 'follow_context']],
+
+                //--- Moderation related: For 'admins'.
+                ['group' => 'action_required', 'unit' => $sModule, 'action' => 'pending_approval', 'types' => ['personal']],
+            ],
+            'alerts' => [
+                ['unit' => $sModule, 'action' => 'added'],
+                ['unit' => $sModule, 'action' => 'edited'],
+                ['unit' => $sModule, 'action' => 'deleted'],
+
+                ['unit' => $sModule, 'action' => 'approved'],
+
+                ['unit' => $sModule, 'action' => 'commentPost'],
+                ['unit' => $sModule, 'action' => 'commentRemoved'],
+
+                ['unit' => $sModule, 'action' => 'replyPost'],
+                ['unit' => $sModule, 'action' => 'replyRemoved'],
+
+                ['unit' => $sModule, 'action' => 'doVote'],
+                ['unit' => $sModule, 'action' => 'undoVote'],
+
+                ['unit' => $sModule . '_reactions', 'action' => 'doVote'],
+                ['unit' => $sModule . '_reactions', 'action' => 'undoVote'],
+
+                ['unit' => $sModule, 'action' => 'doVoteUp'],
+                ['unit' => $sModule, 'action' => 'doVoteDown'],
+
+                //--- Moderation related: For 'admins'.
+                ['unit' => $sModule, 'action' => 'pending_approval'],
+            ]
+        ];
+
+        if(!empty($this->_oConfig->CNF['FIELDS_DELAYED_PROCESSING'])) {
+            $aResult['handlers'] = array_merge($aResult['handlers'], [
+                ['group' => $sModule . '_object_publish_failed', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'publish_failed', 'module_name' => $sModule, 'module_method' => 'get_notifications_post_publish_failed', 'module_class' => 'Module', 'module_event_privacy' => ''],
+                ['group' => $sModule . '_object_publish_succeeded', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'publish_succeeded', 'module_name' => $sModule, 'module_method' => 'get_notifications_post_publish_succeeded', 'module_class' => 'Module', 'module_event_privacy' => ''],
+            ]);
+
+            $aResult['settings'] = array_merge($aResult['settings'], [
+                ['group' => 'content_updated', 'unit' => $sModule, 'action' => 'publish_failed', 'types' => ['personal']],
+                ['group' => 'content_updated', 'unit' => $sModule, 'action' => 'publish_succeeded', 'types' => ['personal']],
+            ]);
+
+            $aResult['alerts'] = array_merge($aResult['alerts'], [
+                ['unit' => $sModule, 'action' => 'publish_failed'],
+                ['unit' => $sModule, 'action' => 'publish_succeeded'],
+            ]);
+        }
+
+        return $aResult;
+    }
+
+    /**
+     * Entry post for Notifications module
+     */
+    public function serviceGetNotificationsPost($aEvent)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $aContentInfo = $this->_oDb->getContentInfoById($aEvent['object_id']);
+        if(empty($aContentInfo) || !is_array($aContentInfo))
+            return [];
+
+        $sEntryUrl = bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $CNF['URI_VIEW_ENTRY'] . '&id=' . $aContentInfo[$CNF['FIELD_ID']]), '{bx_url_root}');
+        $sEntryCaption = isset($aContentInfo[$CNF['FIELD_TITLE']]) ? $aContentInfo[$CNF['FIELD_TITLE']] : strmaxtextlen($aContentInfo[$CNF['FIELD_TEXT']], 20, '...');
+        $sEntrySummary = isset($aContentInfo[$CNF['FIELD_TEXT']]) ? $aContentInfo[$CNF['FIELD_TEXT']] : '';
+
+        return [
+            'entry_sample' => $CNF['T']['txt_sample_single'],
+            'entry_url' => $sEntryUrl,
+            'entry_caption' => $sEntryCaption,
+            'entry_summary' => $sEntrySummary,
+            'entry_author' => $aContentInfo[$CNF['FIELD_AUTHOR']],
+            'entry_privacy' => '', //may be empty or not specified. In this case Public privacy will be used.
+            'lang_key' => '', //may be empty or not specified, or a string, or an array('site' => '...', 'email' => '...', 'push' => '...'). In this case of empty/not specified the default one from Notification module will be used.
+            /*
+             * Custom settings for email and/or push notifications can be provided here. 
+             * Only necessary parts of 'settings' array can be used.
+             * 
+            'settings' => array(
+                'email' => array(
+                    'template' => '',   //--- custom email template
+                    'markers' => '',    //--- markers to parse email parts (subject, body) with
+                    'subject' => ''     //--- custom email subject
+                ),
+                'push' => array(
+                    'subject' => ''     //--- custom push notification subject
+                )
+            )
+             */
+        ];
+    }
+
+    public function serviceGetNotificationsPostPendingApproval($aEvent)
+    {
+        $aResult = $this->serviceGetNotificationsPost($aEvent);
+
+        /**
+         * Unset Entry Author because the notification is addressed to 'admins'.
+         */
+        if(isset($aResult['entry_author']))
+            unset($aResult['entry_author']);
+
+        return $aResult;
+    }
+
+    public function serviceGetNotificationsPostApproved($aEvent)
+    {
+        return $this->serviceGetNotificationsPost($aEvent);
+    }
+
+    public function serviceGetNotificationsPostPublishFailed($aEvent)
+    {
+        return $this->serviceGetNotificationsPost($aEvent);
+    }
+
+    public function serviceGetNotificationsPostPublishSucceeded($aEvent)
+    {
+        return $this->serviceGetNotificationsPost($aEvent);
+    }
+
+
+    /**
+     * Entry post comment for Notifications module
+     */
+    public function serviceGetNotificationsComment($aEvent)
+    {
+    	$CNF = &$this->_oConfig->CNF;
+
+    	$iContentId = (int)$aEvent['object_id'];
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if(empty($aContentInfo) || !is_array($aContentInfo))
+            return [];
+
+        $oComment = BxDolCmts::getObjectInstance($CNF['OBJECT_COMMENTS'], $iContentId);
+        if(!$oComment || !$oComment->isEnabled())
+            return [];
+
+        $sEntryUrl = bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $CNF['URI_VIEW_ENTRY'] . '&id=' . $aContentInfo[$CNF['FIELD_ID']]), '{bx_url_root}');
+        $sEntryCaption = isset($aContentInfo[$CNF['FIELD_TITLE']]) ? $aContentInfo[$CNF['FIELD_TITLE']] : strmaxtextlen($aContentInfo[$CNF['FIELD_TEXT']], 20, '...');
+        $sEntrySummary = isset($aContentInfo[$CNF['FIELD_TEXT']]) ? $aContentInfo[$CNF['FIELD_TEXT']] : '';
+
+        $iCommentId = (int)$aEvent['subobject_id'];
+
+        return [
+            'entry_sample' => $CNF['T']['txt_sample_single'],
+            'entry_url' => $sEntryUrl,
+            'entry_caption' => $sEntryCaption,
+            'entry_summary' => $sEntrySummary,
+            'entry_author' => $aContentInfo[$CNF['FIELD_AUTHOR']],
+            'subentry_sample' => $CNF['T']['txt_sample_comment_single'],
+            'subentry_url' => bx_absolute_url($oComment->getViewUrl($iCommentId, false), '{bx_url_root}'),
+            'subentry_summary' => $oComment->getViewText($iCommentId),
+            'lang_key' => '', //may be empty or not specified. In this case the default one from Notification module will be used.
+        ];
+    }
+
+	/**
+     * Entry post reply for Notifications module
+     */
+    public function serviceGetNotificationsReply($aEvent)
+    {
+    	$CNF = &$this->_oConfig->CNF;
+
+    	$oComment = BxDolCmts::getObjectInstance($CNF['OBJECT_COMMENTS'], 0, false);
+        if(!$oComment || !$oComment->isEnabled())
+            return [];
+
+    	$iParentId = (int)$aEvent['object_id'];
+        $aParentInfo = $oComment->getQueryObject()->getCommentsBy(array('type' => 'id', 'id' => $iParentId));
+        if(empty($aParentInfo) || !is_array($aParentInfo))
+            return [];
+
+        $iObjectId = (int)$aParentInfo['cmt_object_id'];
+        $oComment->init($iObjectId);
+
+        $iCommentId = (int)$aEvent['subobject_id'];
+
+        return [
+            'object_id' => $iObjectId,
+            'entry_sample' => '_cmt_txt_sample_comment_single',
+            'entry_url' => bx_absolute_url($oComment->getViewUrl($iParentId, false), '{bx_url_root}'),
+            'entry_caption' => strmaxtextlen($aParentInfo['cmt_text'], 20, '...'),
+            'entry_summary' => $oComment->getViewText($iParentId),
+            'entry_author' => (int)$aParentInfo['cmt_author_id'],
+            'subentry_sample' => '_cmt_txt_sample_reply_to',
+            'subentry_url' => bx_absolute_url($oComment->getViewUrl($iCommentId, false), '{bx_url_root}'),
+            'subentry_summary' => $oComment->getViewText($iCommentId),
+            'lang_key' => '', //may be empty or not specified. In this case the default one from Notification module will be used.
+        ];
+    }
+
+	/**
+     * Entry post vote for Notifications module
+     */
+    public function serviceGetNotificationsVote($aEvent)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $iContentId = (int)$aEvent['object_id'];
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if(empty($aContentInfo) || !is_array($aContentInfo))
+            return [];
+
+        $oVote = BxDolVote::getObjectInstance($CNF['OBJECT_VOTES'], $iContentId);
+        if(!$oVote || !$oVote->isEnabled())
+            return [];
+
+        $sEntryUrl = bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $CNF['URI_VIEW_ENTRY'] . '&id=' . $aContentInfo[$CNF['FIELD_ID']]), '{bx_url_root}');
+        $sEntryCaption = isset($aContentInfo[$CNF['FIELD_TITLE']]) ? $aContentInfo[$CNF['FIELD_TITLE']] : strmaxtextlen($aContentInfo[$CNF['FIELD_TEXT']], 20, '...');
+        $sEntrySummary = isset($aContentInfo[$CNF['FIELD_TEXT']]) ? $aContentInfo[$CNF['FIELD_TEXT']] : '';
+
+        return [
+            'entry_sample' => $CNF['T']['txt_sample_single'],
+            'entry_url' => $sEntryUrl,
+            'entry_caption' => $sEntryCaption,
+            'entry_summary' => $sEntrySummary,
+            'entry_author' => $aContentInfo[$CNF['FIELD_AUTHOR']],
+            'subentry_sample' => $CNF['T']['txt_sample_vote_single'],
+            'lang_key' => '', //may be empty or not specified. In this case the default one from Notification module will be used.
+        ];
+    }
+
+    /**
+     * Entry post vote for Notifications module
+     */
+    public function serviceGetNotificationsReaction($aEvent)
+    {
+    	$CNF = &$this->_oConfig->CNF;
+
+    	$iContentId = (int)$aEvent['object_id'];
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if(empty($aContentInfo) || !is_array($aContentInfo))
+            return [];
+
+        $oReaction = BxDolVote::getObjectInstance($CNF['OBJECT_REACTIONS'], $iContentId);
+        if(!$oReaction || !$oReaction->isEnabled())
+            return [];
+
+        $aSubentry = $oReaction->getTrackBy(array('type' => 'id', 'id' => (int)$aEvent['subobject_id']));
+        if(empty($aSubentry) || !is_array($aSubentry))
+            return [];
+
+        $aSubentrySampleParams = array();
+        $aReaction = $oReaction->getReaction($aSubentry['reaction']);
+        if(!empty($aReaction['title']))
+            $aSubentrySampleParams[] = $aReaction['title'];
+        else
+            $aSubentrySampleParams[] = '_undefined';
+
+        $sEntryUrl = bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $CNF['URI_VIEW_ENTRY'] . '&id=' . $aContentInfo[$CNF['FIELD_ID']]), '{bx_url_root}');
+        $sEntryCaption = isset($aContentInfo[$CNF['FIELD_TITLE']]) ? $aContentInfo[$CNF['FIELD_TITLE']] : strmaxtextlen($aContentInfo[$CNF['FIELD_TEXT']], 20, '...');
+        $sEntrySummary = isset($aContentInfo[$CNF['FIELD_TEXT']]) ? $aContentInfo[$CNF['FIELD_TEXT']] : '';
+
+        return [
+            'entry_sample' => $CNF['T']['txt_sample_single'],
+            'entry_url' => $sEntryUrl,
+            'entry_caption' => $sEntryCaption,
+            'entry_summary' => $sEntrySummary,
+            'entry_author' => $aContentInfo[$CNF['FIELD_AUTHOR']],
+            'subentry_sample' => $CNF['T']['txt_sample_reaction_single'],
+            'subentry_sample_params' => $aSubentrySampleParams,
+            'lang_key' => '', //may be empty or not specified. In this case the default one from Notification module will be used.
+        ];
+    }
+
+    /**
+     * Entry post score -> vote up for Notifications module
+     */
+    public function serviceGetNotificationsScoreUp($aEvent)
+    {
+    	return $this->_serviceGetNotificationsScore('up', $aEvent);
+    }
+
+    /**
+     * Entry post score -> vote up for Notifications module
+     */
+    public function serviceGetNotificationsScoreDown($aEvent)
+    {
+    	return $this->_serviceGetNotificationsScore('down', $aEvent);
+    }
+
+    protected function _serviceGetNotificationsScore($sType, $aEvent)
+    {
+    	$CNF = &$this->_oConfig->CNF;
+
+    	$iContentId = (int)$aEvent['object_id'];
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if(empty($aContentInfo) || !is_array($aContentInfo))
+            return array();
+
+        $oScore = BxDolScore::getObjectInstance($CNF['OBJECT_SCORES'], $iContentId);
+        if(!$oScore || !$oScore->isEnabled())
+            return array();
+
+        $sEntryUrl = bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $CNF['URI_VIEW_ENTRY'] . '&id=' . $aContentInfo[$CNF['FIELD_ID']]), '{bx_url_root}');
+        $sEntryCaption = isset($aContentInfo[$CNF['FIELD_TITLE']]) ? $aContentInfo[$CNF['FIELD_TITLE']] : strmaxtextlen($aContentInfo[$CNF['FIELD_TEXT']], 20, '...');
+        $sEntrySummary = isset($aContentInfo[$CNF['FIELD_TEXT']]) ? $aContentInfo[$CNF['FIELD_TEXT']] : '';
+
+        return [
+            'entry_sample' => $CNF['T']['txt_sample_single'],
+            'entry_url' => $sEntryUrl,
+            'entry_caption' => $sEntryCaption,
+            'entry_summary' => $sEntrySummary,
+            'entry_author' => $aContentInfo[$CNF['FIELD_AUTHOR']],
+            'subentry_sample' => $CNF['T']['txt_sample_score_' . $sType . '_single'],
+            'lang_key' => '', //may be empty or not specified. In this case the default one from Notification module will be used.
+        ];
+    }
+
+    /**
+     * Data for Reputation module
+     */
+    public function serviceGetReputationData()
+    {
+    	$sModule = $this->_aModule['name'];
+
+        $aResult = [
+            'handlers' => [
+                ['group' => $sModule . '_object', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'added', 'points_active' => 3, 'points_passive' => 0],
+                ['group' => $sModule . '_object', 'type' => 'delete', 'alert_unit' => $sModule, 'alert_action' => 'deleted', 'points_active' => -3, 'points_passive' => 0],
+
+                ['group' => $sModule . '_comment', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'commentPost', 'points_active' => 2, 'points_passive' => 1],
+                ['group' => $sModule . '_comment', 'type' => 'delete', 'alert_unit' => $sModule, 'alert_action' => 'commentRemoved', 'points_active' => -2, 'points_passive' => -1],
+
+                ['group' => $sModule . '_vote', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'doVote', 'points_active' => 1, 'points_passive' => 1],
+                ['group' => $sModule . '_vote', 'type' => 'delete', 'alert_unit' => $sModule, 'alert_action' => 'undoVote', 'points_active' => -1, 'points_passive' => -1],
+
+                ['group' => $sModule . '_reaction', 'type' => 'insert', 'alert_unit' => $sModule . '_reactions', 'alert_action' => 'doVote', 'points_active' => 1, 'points_passive' => 1],
+                ['group' => $sModule . '_reaction', 'type' => 'delete', 'alert_unit' => $sModule . '_reactions', 'alert_action' => 'undoVote', 'points_active' => -1, 'points_passive' => -1],
+
+                ['group' => $sModule . '_score_up', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'doVoteUp', 'points_active' => 1, 'points_passive' => 1],
+                ['group' => $sModule . '_score_up', 'type' => 'delete', 'alert_unit' => $sModule, 'alert_action' => 'undoVoteUp', 'points_active' => -1, 'points_passive' => -1],
+
+                ['group' => $sModule . '_score_down', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'doVoteDown', 'points_active' => 1, 'points_passive' => -1],
+                ['group' => $sModule . '_score_down', 'type' => 'delete', 'alert_unit' => $sModule, 'alert_action' => 'undoVoteDown', 'points_active' => -1, 'points_passive' => 1],
+            ],
+            'alerts' => [
+                ['unit' => $sModule, 'action' => 'added'],
+                ['unit' => $sModule, 'action' => 'deleted'],
+
+                ['unit' => $sModule, 'action' => 'commentPost'],
+                ['unit' => $sModule, 'action' => 'commentRemoved'],
+
+                ['unit' => $sModule, 'action' => 'doVote'],
+                ['unit' => $sModule, 'action' => 'undoVote'],
+
+                ['unit' => $sModule . '_reactions', 'action' => 'doVote'],
+                ['unit' => $sModule . '_reactions', 'action' => 'undoVote'],
+
+                ['unit' => $sModule, 'action' => 'doVoteUp'],
+                ['unit' => $sModule, 'action' => 'undoVoteUp'],
+                ['unit' => $sModule, 'action' => 'doVoteDown'],
+                ['unit' => $sModule, 'action' => 'undoVoteDown'],
+            ]
+        ];
+
+        return $aResult;
+    }
+
+    /**
+     * Data for Timeline module
+     */
+    public function serviceGetTimelineData()
+    {
+    	$sModule = $this->_aModule['name'];
+
+        return array(
+            'handlers' => array(
+                array('group' => $sModule . '_object', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'added', 'module_name' => $sModule, 'module_method' => 'get_timeline_post', 'module_class' => 'Module',  'groupable' => 0, 'group_by' => ''),
+                array('group' => $sModule . '_object', 'type' => 'update', 'alert_unit' => $sModule, 'alert_action' => 'edited'),
+                array('group' => $sModule . '_object', 'type' => 'delete', 'alert_unit' => $sModule, 'alert_action' => 'deleted')
+            ),
+            'alerts' => array(
+                array('unit' => $sModule, 'action' => 'added'),
+                array('unit' => $sModule, 'action' => 'edited'),
+                array('unit' => $sModule, 'action' => 'deleted'),
+            )
+        );
+    }
+
+    /**
+     * Entry post for Timeline module
+     */
+    public function serviceGetTimelinePost($aEvent, $aBrowseParams = array())
+    {
+        $aContentInfo = $this->_oDb->getContentInfoById($aEvent['object_id']);
+        if(empty($aContentInfo) || !is_array($aContentInfo))
+            return false;
+
+        $CNF = &$this->_oConfig->CNF;
+
+        if((!empty($CNF['FIELD_STATUS']) && $aContentInfo[$CNF['FIELD_STATUS']] != 'active') || (!empty($CNF['FIELD_STATUS_ADMIN']) && $aContentInfo[$CNF['FIELD_STATUS_ADMIN']] != 'active'))
+            return false;
+
+        $bCache = true;
+        $iUserId = $this->getUserId();
+        $iAuthorId = (int)$aContentInfo[$CNF['FIELD_AUTHOR']];
+        $iAuthorIdAbs = abs($iAuthorId);
+
+        /**
+         * Don't show anonymous posts on the post's owner timeline
+         * and don't cache them when they're viewed by their authors. 
+         */
+        if($iAuthorId < 0 && ((is_numeric($aEvent['owner_id']) && $iAuthorIdAbs == (int)$aEvent['owner_id']) || (is_array($aEvent['owner_id']) && in_array($iAuthorIdAbs, $aEvent['owner_id'])))) {
+            if($iAuthorIdAbs != $iUserId)
+                return false;
+
+            $bCache = false;
+        }
+
+        //--- Views
+        $oViews = isset($CNF['OBJECT_VIEWS']) ? BxDolView::getObjectInstance($CNF['OBJECT_VIEWS'], $aContentInfo[$CNF['FIELD_ID']]) : null;
+
+        $aViews = array();
+        if ($oViews && $oViews->isEnabled())
+            $aViews = array(
+                'system' => $CNF['OBJECT_VIEWS'],
+                'object_id' => $aContentInfo[$CNF['FIELD_ID']],
+                'count' => $aContentInfo['views']
+            );
+
+        //--- Votes
+        $oVotes = isset($CNF['OBJECT_VOTES']) ? BxDolVote::getObjectInstance($CNF['OBJECT_VOTES'], $aContentInfo[$CNF['FIELD_ID']]) : null;
+
+        $aVotes = array();
+        if ($oVotes && $oVotes->isEnabled())
+            $aVotes = array(
+                'system' => $CNF['OBJECT_VOTES'],
+                'object_id' => $aContentInfo[$CNF['FIELD_ID']],
+                'count' => $aContentInfo['votes']
+            );
+        
+        //--- Reactions
+        $oReactions = isset($CNF['OBJECT_REACTIONS']) ? BxDolVote::getObjectInstance($CNF['OBJECT_REACTIONS'], $aContentInfo[$CNF['FIELD_ID']]) : null;
+
+        $aReactions = array();
+        if ($oReactions && $oReactions->isEnabled())
+            $aReactions = array(
+                'system' => $CNF['OBJECT_REACTIONS'],
+                'object_id' => $aContentInfo[$CNF['FIELD_ID']],
+                'count' => $aContentInfo['rvotes']
+            );
+
+        //--- Scores
+        $oScores = isset($CNF['OBJECT_SCORES']) ? BxDolScore::getObjectInstance($CNF['OBJECT_SCORES'], $aContentInfo[$CNF['FIELD_ID']]) : null;
+
+        $aScores = array();
+        if ($oScores && $oScores->isEnabled())
+            $aScores = array(
+                'system' => $CNF['OBJECT_SCORES'],
+                'object_id' => $aContentInfo[$CNF['FIELD_ID']],
+                'score' => $aContentInfo['score']
+            );
+
+        //--- Reports
+        $oReports = isset($CNF['OBJECT_REPORTS']) ? BxDolReport::getObjectInstance($CNF['OBJECT_REPORTS'], $aContentInfo[$CNF['FIELD_ID']]) : null;
+
+        $aReports = array();
+        if ($oReports && $oReports->isEnabled())
+            $aReports = array(
+                'system' => $CNF['OBJECT_REPORTS'],
+                'object_id' => $aContentInfo[$CNF['FIELD_ID']],
+                'count' => $aContentInfo['reports']
+            );
+
+        //--- Comments
+        $oCmts = isset($CNF['OBJECT_COMMENTS']) ? BxDolCmts::getObjectInstance($CNF['OBJECT_COMMENTS'], $aContentInfo[$CNF['FIELD_ID']]) : null;
+
+        $aComments = array();
+        if($oCmts && $oCmts->isEnabled())
+            $aComments = array(
+                'system' => $CNF['OBJECT_COMMENTS'],
+                'object_id' => $aContentInfo[$CNF['FIELD_ID']],
+                'count' => $aContentInfo['comments']
+            );
+
+        //--- Title & Description
+        $sTitle = !empty($aContentInfo[$CNF['FIELD_TITLE']]) ? $aContentInfo[$CNF['FIELD_TITLE']] : '';
+        if(empty($sTitle) && !empty($aContentInfo[$CNF['FIELD_TEXT']]))
+            $sTitle = $aContentInfo[$CNF['FIELD_TEXT']];
+
+        $iOwnerId = $iAuthorIdAbs;
+        if(isset($aEvent['object_privacy_view']) && (int)$aEvent['object_privacy_view'] < 0)
+            $iOwnerId = abs($aEvent['object_privacy_view']);
+
+        $aContent = $this->_getContentForTimelinePost($aEvent, $aContentInfo, $aBrowseParams);
+        if(isset($aContent['_cache'])) {
+            if($bCache)
+                $bCache = (bool)$aContent['_cache'];
+
+            unset($aContent['_cache']);
+        }
+
+        return [
+            '_cache' => $bCache,
+            'owner_id' => $iOwnerId,
+            'object_owner_id' => $iAuthorId,
+            'icon' => !empty($CNF['ICON']) ? $CNF['ICON'] : '',
+            'sample' => isset($CNF['T']['txt_sample_single_with_article']) ? $CNF['T']['txt_sample_single_with_article'] : $CNF['T']['txt_sample_single'],
+            'sample_wo_article' => $CNF['T']['txt_sample_single'],
+            'sample_action' => isset($CNF['T']['txt_sample_action']) ? $CNF['T']['txt_sample_action'] : '',
+            'url' => bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $CNF['URI_VIEW_ENTRY'] . '&id=' . $aContentInfo[$CNF['FIELD_ID']])),
+            'content' => $aContent, //a string to display or array to parse default template before displaying.
+            'date' => $aContentInfo[$CNF['FIELD_ADDED']],
+            'views' => $aViews,
+            'votes' => $aVotes,
+            'reactions' => $aReactions,
+            'scores' => $aScores,
+            'reports' => $aReports,
+            'comments' => $aComments,
+            'title' => $sTitle, //may be empty.
+            'description' => '' //may be empty.
+        ];
+    }
+
+    public function serviceGetTimelinePostAllowedView($aEvent)
+    {
+        $iContentId = (int)$aEvent['object_id'];
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if(empty($aContentInfo) || !is_array($aContentInfo))
+            return _t('_sys_txt_access_denied');
+
+        return $this->serviceCheckAllowedViewForProfile($aContentInfo);
+    }
+
+    /**
+     * Check particular action permission without content
+     * @param $sAction action to check, for example: Browse, Add
+     * @param $iContentId content ID
+     * @return message on error, or CHECK_ACTION_RESULT_ALLOWED when allowed
+     */ 
+    public function serviceCheckAllowed($sAction, $isPerformAction = false)
+    {
+        $sMethod = 'checkAllowed' . bx_gen_method_name($sAction);
+        if (!method_exists($this, $sMethod))
+            return _t('_sys_request_method_not_found_cpt');
+
+        return $this->$sMethod($isPerformAction);
+    }
+    
+    /**
+     * Check particular action permission with content
+     * @param $sAction action to check, for example: View, Edit
+     * @param $iContentId content ID
+     * @return message on error, or CHECK_ACTION_RESULT_ALLOWED when allowed
+     */ 
+    public function serviceCheckAllowedWithContent($sAction, $iContentId, $isPerformAction = false)
+    {
+        if (!$iContentId || !($aContentInfo = $this->_oDb->getContentInfoById($iContentId)))
+            return _t('_sys_request_page_not_found_cpt');
+
+        $sMethod = 'checkAllowed' . bx_gen_method_name($sAction);
+        if (!method_exists($this, $sMethod))
+            return _t('_sys_request_method_not_found_cpt');
+
+        return $this->$sMethod($aContentInfo, $isPerformAction);
+    }
+
+    /**
+     * Check particular action permission with content for specified profile
+     * @param $sAction action to check, for example: View, Edit
+     * @param $iContentId content ID
+     * @param $iProfileId profile ID which the permissions to be cheked for
+     * @return message on error, or CHECK_ACTION_RESULT_ALLOWED when allowed
+     */ 
+    public function serviceCheckAllowedWithContentForProfile($sAction, $iContentId, $iProfileId, $isPerformAction = false)
+    {
+        if (!$iContentId || !($aContentInfo = $this->_oDb->getContentInfoById($iContentId)))
+            return _t('_sys_request_page_not_found_cpt');
+
+        $sMethod = 'checkAllowed' . bx_gen_method_name($sAction) . 'ForProfile';
+        if (!method_exists($this, $sMethod))
+            return _t('_sys_request_method_not_found_cpt');
+
+        return $this->$sMethod($aContentInfo, $iProfileId, $isPerformAction);
+    }
+    
+    public function serviceCheckAllowedCommentsView($iContentId, $sObjectComments) 
+    {
+        //negative id used in comments for reports
+        if ($iContentId < 0)
+            return CHECK_ACTION_RESULT_ALLOWED;
+        
+        return $this->serviceCheckAllowedWithContent('comments_view', $iContentId);
+    }
+    
+    public function serviceCheckAllowedCommentsPost($iContentId, $sObjectComments) 
+    {
+        //negative id used in comments for reports
+        if ($iContentId < 0)
+            return CHECK_ACTION_RESULT_ALLOWED;
+        
+        return $this->serviceCheckAllowedWithContent('comments_post', $iContentId);
+    }
+
+    public function serviceGetContentOwnerProfileId ($iContentId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        // file owner must be author of the content or profile itself in case of profile based module
+        if ($iContentId) {
+            $sModule = $this->getName();
+            if ($this instanceof iBxDolProfileService && BxDolService::call($sModule, 'act_as_profile')) {
+                $oProfile = BxDolProfile::getInstanceByContentAndType($iContentId, $sModule);
+            }
+            else {
+                $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+                $iContentAuthor = (int)$aContentInfo[$CNF['FIELD_AUTHOR']];
+                if($iContentAuthor < 0)
+                    $iContentAuthor = abs($iContentAuthor);
+
+                $oProfile = $aContentInfo ? BxDolProfile::getInstance($iContentAuthor) : null;
+            }
+
+            $iProfileId = $oProfile ? $oProfile->id() : bx_get_logged_profile_id();
+        }
+        else
+            $iProfileId = bx_get_logged_profile_id();
+
+        return $iProfileId;
+    }
+	
+    public function serviceGetBadges($iContentId,  $bIsSingle = false, $bIsCompact  = false)
+    {
+        $sModule = $this->getName();
+
+        $oBadges = BxDolBadges::getInstance();
+        $aBadges = $oBadges->getData(array(
+            'type' => ($bIsSingle ? 'by_module&object2_single' : 'by_module&object2'),
+            'module' => $sModule,
+            'object_id' => $iContentId
+        ));
+
+        $sResult = false;
+        /**
+         * @hooks
+         * @hookdef hook-system-get_badges 'system', 'get_badges' - hook to override bages assigned to an entry (content)
+         * - $unit_name - equals `system`
+         * - $action - equals `get_badges`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `module` - [string] module name
+         *      - `content_id` - [int] content id
+         *      - `is_single` - [boolean] only one badge can be used or not
+         *      - `is_compact` - [boolean] badge in compact mode or not
+         *      - `badges` - [array] a list of all badges assigned to the content
+         *      - `override_result` - [string] or [boolean] by ref, if string is returned then it will be used as resulting badges code, can be overridden in hook processing
+         * @hook @ref hook-system-get_badges
+         */
+        bx_alert('system', 'get_badges', 0, 0, [
+            'module' => $sModule, 
+            'content_id' => $iContentId,
+            'is_single' => $bIsSingle,
+            'is_compact' => $bIsCompact,
+            'badges' => $aBadges, 
+            'override_result' => &$sResult
+        ]);
+        if($sResult !== false)
+            return $sResult;
+
+        if(empty($aBadges) || !is_array($aBadges))
+            return '';
+
+        if (bx_is_api()){
+            return $aBadges;
+        }
+        
+        if($bIsSingle)
+            return BxDolService::call('system', 'get_badge', array($aBadges[0], $bIsCompact), 'TemplServices');
+
+        $aBadgesOutput = array();
+        foreach($aBadges as $aBadge)
+            $aBadgesOutput[] =  array('badge' => BxDolService::call('system', 'get_badge', array($aBadge, $bIsCompact), 'TemplServices'));
+
+        return $this->_oTemplate->parseHtmlByName('badges.html', array(
+            'bx_repeat:items' => $aBadgesOutput,
+        ));
+    }
+    
+    /**
+     * @page service Service Calls
+     * @section bx_base_general Base General
+     * @subsection bx_base_general-page_blocks Page Blocks
+     * @subsubsection bx_base_general-categories_multi_list categories_multi_list
+     * 
+     * @code bx_srv('bx_posts', 'categories_multi_list', [...]); @endcode
+     * 
+     * Display multi-categorories block with number of posts in each category
+     * @param $bDisplayEmptyCats display empty categories
+     * 
+     * @see BxBaseModGeneralModule::serviceCategoriesMultiList
+     */
+    /** 
+     * @ref bx_base_general-categories_multi_list "categories_multi_list"
+     */
+    public function serviceCategoriesMultiList($bDisplayEmptyCats = true)
+    {
+        $aContextInfo = bx_get_page_info();
+
+        $mProfileContextId = false;
+        if ($aContextInfo !== false)
+            $mProfileContextId = $aContextInfo['context_profile_id'];
+        
+        $oCategories = BxDolCategories::getInstance();
+        if ($mProfileContextId)
+            $aCats = $oCategories->getData([
+                'type' => 'by_module&context_with_num', 
+                'module' => $this->getName(), 
+                'context_id' => $mProfileContextId
+            ]);
+        else{
+            $aCats = $oCategories->getData([
+                'type' => 'by_module_with_num', 
+                'module' => $this->getName()
+            ]);
+        }
+        $aVars = array('bx_repeat:cats' => array());
+        foreach ($aCats as $oCat) {
+            $sValue = $oCat['value'];
+            $iNum = $oCat['num'];
+            
+            $aVars['bx_repeat:cats'][] = array(
+                'url' => $oCategories->getUrl($this->getName(), $sValue, $mProfileContextId ? '&context_id=' . $mProfileContextId : ''),
+                'name' => _t($sValue),
+                'value' => $sValue,
+                'num' => $iNum,
+            );
+        }
+        
+        if (!$aVars['bx_repeat:cats'])
+            return '';
+
+        return $this->_oTemplate->parseHtmlByName('category_list_multi.html', $aVars);
+    }
+
+    /**
+     * ======
+     * PERMISSION METHODS
+     * ======
+     */
+    public function serviceCheckAllowedViewForProfile ($aDataEntry, $isPerformAction = false, $iProfileId = false)
+    {
+        $mixedResult = $this->_serviceCheckAllowedViewForProfile ($aDataEntry, $isPerformAction, $iProfileId);
+
+        /**
+         * @hooks
+         * @hookdef hook-system-check_allowed_view 'system', 'check_allowed_view' - hook to override the result of checking whether 'view' action is allowed or not to specified profile
+         * - $unit_name - equals `system`
+         * - $action - equals `check_allowed_view`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `module` - [string] module name
+         *      - `content_info` - [array] content info array as key&value pairs
+         *      - `profile_id` - [boolean] or [int] profile id to be checked the availability of the action to
+         *      - `override_result` - [string] or [int] by ref, check action result, can be overridden in hook processing. Return string with an error if action isn't allowed or CHECK_ACTION_RESULT_ALLOWED, @see BxDolAcl
+         * @hook @ref hook-system-check_allowed_view
+         */
+        bx_alert('system', 'check_allowed_view', 0, 0, [
+            'module' => $this->getName(), 
+            'content_info' => $aDataEntry, 
+            'profile_id' => $iProfileId, 
+            'override_result' => &$mixedResult
+        ]);
+
+        return $mixedResult;
+    }
+
+    public function checkAllowedSetThumb ($iContentId = 0)
+    {
+        return CHECK_ACTION_RESULT_ALLOWED;
+    }
+    
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden. So make sure to make "true === " checking.
+     */
+    public function checkAllowedBrowse ()
+    {
+        $mixedResult = null;
+        /**
+         * @hooks
+         * @hookdef hook-system-check_allowed_browse 'system', 'check_allowed_browse' - hook to override the result of checking whether 'browse' action is allowed or not to currently logged in profile
+         * It's equivalent to @ref hook-system-check_allowed_view 
+         * except `content_info` parameter in $extra_params is missing
+         * @hook @ref hook-system-check_allowed_browse
+         */
+        bx_alert('system', 'check_allowed_browse', 0, 0, [
+            'module' => $this->getName(), 
+            'profile_id' => $this->_iProfileId, 
+            'override_result' => &$mixedResult
+        ]);
+        if($mixedResult !== null)
+            return $mixedResult;
+
+        return CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden. So make sure to make strict(===) checking.
+     */
+    public function checkAllowedView ($aDataEntry, $isPerformAction = false)
+    {
+        return $this->serviceCheckAllowedViewForProfile ($aDataEntry, $isPerformAction);
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden. So make sure to make strict(===) checking.
+     */
+    public function checkAllowedViewForProfile ($aDataEntry, $iProfileId, $isPerformAction = false)
+    {
+        return $this->serviceCheckAllowedViewForProfile ($aDataEntry, $isPerformAction, $iProfileId);
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden. So make sure to make strict(===) checking.
+     */
+    public function checkAllowedAdd ($isPerformAction = false)
+    {
+        // check ACL
+        $aCheck = checkActionModule($this->_iProfileId, 'create entry', $this->getName(), $isPerformAction);
+        if ($aCheck[CHECK_ACTION_RESULT] !== CHECK_ACTION_RESULT_ALLOWED)
+            return $aCheck[CHECK_ACTION_MESSAGE];
+        return CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden. So make sure to make strict(===) checking.
+     */
+    public function checkAllowedApprove ($aDataEntry, $isPerformAction = false)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        $sError = '_sys_txt_access_denied';
+
+        if($this->_oConfig->isAutoApproveEnabled())
+            return _t($sError);
+
+        if($aDataEntry[$CNF['FIELD_STATUS_ADMIN']] != BX_BASE_MOD_GENERAL_STATUS_PENDING)
+            return _t($sError);
+
+        // moderator always have access
+        if ($this->_isModerator($isPerformAction))
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        // check for context's admins 
+        if (!empty($CNF['FIELD_ALLOW_VIEW_TO']) && (int)$aDataEntry[$CNF['FIELD_ALLOW_VIEW_TO']] < 0) {
+            $oProfile = BxDolProfile::getInstance(-(int)$aDataEntry[$CNF['FIELD_ALLOW_VIEW_TO']]);
+            if ($oProfile){
+                $sModule = $oProfile->getModule();
+                $aEntity = BxDolRequest::serviceExists($sModule, 'get_all') ? BxDolService::call($sModule, 'get_all', array(array('type' => 'id', 'id' => $oProfile->getContentId()))) : array();
+
+                $oModule = BxDolModule::getInstance($sModule);
+
+                // check for context's extra roles with rights
+                if (method_exists($oModule, 'isAllowedModuleActionByProfile')) {
+                    $bResult = $oModule->isAllowedModuleActionByProfile($oProfile->getContentId(), $this->getName(), 'edit_any');
+                    if ($bResult !== NULL) return $bResult;
+                }
+
+                // if allowed edit a group then allowed to edit anything inside its context
+                if(isset($aEntity) && $oModule->checkAllowedEdit($aEntity) === CHECK_ACTION_RESULT_ALLOWED){
+                    return CHECK_ACTION_RESULT_ALLOWED;
+                }
+            }
+        }
+
+        return _t($sError);
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden. So make sure to make strict(===) checking.
+     */
+    public function checkAllowedEdit ($aDataEntry, $isPerformAction = false)
+    {
+        // moderator and owner always have access
+        if ($this->isEntryAuthor($aDataEntry, $this->_iProfileId) || $this->_isModerator($isPerformAction))
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        // check for context's admins 
+        if (!empty($this->_oConfig->CNF['FIELD_ALLOW_VIEW_TO']) && (int)$aDataEntry[$this->_oConfig->CNF['FIELD_ALLOW_VIEW_TO']] < 0) {
+            $oProfile = BxDolProfile::getInstance(-(int)$aDataEntry[$this->_oConfig->CNF['FIELD_ALLOW_VIEW_TO']]);
+            if ($oProfile){
+                $sModule = $oProfile->getModule();
+                $aEntity = BxDolRequest::serviceExists($sModule, 'get_all') ? BxDolService::call($sModule, 'get_all', array(array('type' => 'id', 'id' => $oProfile->getContentId()))) : array();
+
+                $oModule = BxDolModule::getInstance($sModule);
+
+                // check for context's extra roles with rights
+                if (method_exists($oModule, 'isAllowedModuleActionByProfile')) {
+                    $bResult = $oModule->isAllowedModuleActionByProfile($oProfile->getContentId(), $this->getName(), 'edit_any');
+                    if ($bResult !== NULL) return $bResult;
+                }
+
+                // if allowed edit a group then allowed to edit anything inside its context
+                if(isset($aEntity) && $oModule->checkAllowedEdit($aEntity) === CHECK_ACTION_RESULT_ALLOWED){
+                    return CHECK_ACTION_RESULT_ALLOWED;
+                }
+            }
+        }
+
+        return _t('_sys_txt_access_denied');
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden. So make sure to make strict(===) checking.
+     */
+    public function checkAllowedDelete (&$aDataEntry, $isPerformAction = false)
+    {
+        // moderator always has access
+        if ($this->_isAdministrator($isPerformAction))
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        // check ACL
+        $aCheck = checkActionModule($this->_iProfileId, 'delete entry', $this->getName(), $isPerformAction);
+        if ($this->isEntryAuthor($aDataEntry, $this->_iProfileId) && $aCheck[CHECK_ACTION_RESULT] === CHECK_ACTION_RESULT_ALLOWED)
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        // check for context's admins 
+        if (isset($this->_oConfig->CNF['FIELD_ALLOW_VIEW_TO']) && (int)$aDataEntry[$this->_oConfig->CNF['FIELD_ALLOW_VIEW_TO']] < 0){
+            $oProfile = BxDolProfile::getInstance(-(int)$aDataEntry[$this->_oConfig->CNF['FIELD_ALLOW_VIEW_TO']]);
+            if ($oProfile){
+                $sModule = $oProfile->getModule();
+                $aEntity = BxDolRequest::serviceExists($sModule, 'get_all') ? BxDolService::call($sModule, 'get_all', array(array('type' => 'id', 'id' => $oProfile->getContentId()))) : array();
+
+                $oModule = BxDolModule::getInstance($sModule);
+
+                // check for context's extra roles with rights
+                if (method_exists($oModule, 'isAllowedModuleActionByProfile')) {
+                    $bResult = $oModule->isAllowedModuleActionByProfile($oProfile->getContentId(), $this->getName(), 'delete_any');
+                    if ($bResult !== NULL) return $bResult;
+                }
+
+                // if allowed delete a group then allowed to delete anything inside its context
+                if(isset($aEntity) && $oModule->checkAllowedDelete($aEntity) === CHECK_ACTION_RESULT_ALLOWED){
+                    return CHECK_ACTION_RESULT_ALLOWED;
+                }
+            }
+        }
+
+        return _t('_sys_txt_access_denied');
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden. So make sure to make strict(===) checking.
+     */
+    public function checkAllowedSetMembership (&$aDataEntry, $isPerformAction = false)
+    {
+        // admin always has access
+        if (isAdmin())
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        // check ACL
+        $aCheck = checkActionModule($this->_iProfileId, 'set acl level', 'system', $isPerformAction);
+        if ($aCheck[CHECK_ACTION_RESULT] !== CHECK_ACTION_RESULT_ALLOWED)
+            return $aCheck[CHECK_ACTION_MESSAGE];
+
+        return CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden. So make sure to make strict(===) checking.
+     */
+    public function checkAllowedEditAnyEntry ($isPerformAction = false)
+    {
+    	return $this->checkAllowedEditAnyEntryForProfile($isPerformAction, $this->_iProfileId);
+    }
+    
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden. So make sure to make strict(===) checking.
+     */
+    public function checkAllowedDeleteAnyEntry ($isPerformAction = false)
+    {
+    	return $this->checkAllowedDeleteAnyEntryForProfile($isPerformAction, $this->_iProfileId);
+    }
+    
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden. So make sure to make strict(===) checking.
+     */
+    public function checkAllowedEditAnyEntryForProfile ($isPerformAction = false, $iProfileId = false)
+    {
+        if(!$iProfileId)
+            $iProfileId = $this->_iProfileId;
+
+    	$aCheck = checkActionModule($iProfileId, MEMBERSHIP_ACTION_EDIT_ANY_ENTRY, $this->getName(), $isPerformAction);
+    	if($aCheck[CHECK_ACTION_RESULT] === CHECK_ACTION_RESULT_ALLOWED)
+    		return CHECK_ACTION_RESULT_ALLOWED;
+
+    	return _t('_sys_txt_access_denied');
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden. So make sure to make strict(===) checking.
+     */
+    public function checkAllowedDeleteAnyEntryForProfile ($isPerformAction = false, $iProfileId = false)
+    {
+        if(!$iProfileId)
+            $iProfileId = $this->_iProfileId;
+
+    	$aCheck = checkActionModule($iProfileId, MEMBERSHIP_ACTION_DELETE_ANY_ENTRY, $this->getName(), $isPerformAction);
+    	if($aCheck[CHECK_ACTION_RESULT] === CHECK_ACTION_RESULT_ALLOWED)
+    		return CHECK_ACTION_RESULT_ALLOWED;
+
+    	return _t('_sys_txt_access_denied');
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden. So make sure to make "true === " checking.
+     */
+    public function checkAllowedCommentsView ($aContentInfo, $isPerformAction = false)
+    {
+        return $this->checkAllowedView ($aContentInfo, $isPerformAction);
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden. So make sure to make "true === " checking.
+     */
+    public function checkAllowedCommentsPost ($aContentInfo, $isPerformAction = false)
+    {
+        return $this->checkAllowedView ($aContentInfo, $isPerformAction);
+    }
+
+    protected function _serviceCheckAllowedViewForProfile ($aDataEntry, $isPerformAction, $iProfileId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!$iProfileId)
+            $iProfileId = $this->_iProfileId;
+
+        if(empty($aDataEntry) || !is_array($aDataEntry))
+            return _t('_sys_txt_not_found');
+
+        // moderator and owner always have access
+        if(!empty($iProfileId) && (abs($aDataEntry[$CNF['FIELD_AUTHOR']]) == (int)$iProfileId || $this->_isModeratorForProfile($isPerformAction, $iProfileId)))
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        // check ACL
+        $aCheck = checkActionModule($iProfileId, 'view entry', $this->getName(), $isPerformAction);
+        if($aCheck[CHECK_ACTION_RESULT] !== CHECK_ACTION_RESULT_ALLOWED)
+            return $aCheck[CHECK_ACTION_MESSAGE];
+
+        // check privacy
+        if(!empty($CNF['OBJECT_PRIVACY_VIEW'])) {
+            $oPrivacy = BxDolPrivacy::getObjectInstance($CNF['OBJECT_PRIVACY_VIEW']);
+            if ($oPrivacy && !$oPrivacy->check($aDataEntry[$CNF['FIELD_ID']], $iProfileId))
+                return _t('_sys_access_denied_to_private_content');
+        }
+
+        return CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    public function _serviceBrowse ($sMode, $aParams = false, $iDesignBox = BX_DB_PADDING_DEF, $bDisplayEmptyMsg = false, $bAjaxPaginate = true, $sClassSearchResult = 'SearchResult')
+    {
+        if (CHECK_ACTION_RESULT_ALLOWED !== ($sMsg = $this->checkAllowedBrowse()))
+            return bx_is_api() ? [bx_api_get_msg($sMsg)] : MsgBox($sMsg);
+
+        bx_import($sClassSearchResult, $this->_aModule);
+        $sClass = $this->_aModule['class_prefix'] . $sClassSearchResult;
+        $o = new $sClass($sMode, $aParams);
+
+        $o->setDesignBoxTemplateId($iDesignBox);
+        $o->setDisplayEmptyMsg($bDisplayEmptyMsg);
+        $o->setAjaxPaginate($bAjaxPaginate);
+        $o->setUnitParams(array('context' => $sMode));
+        if (isset($aParams['condition']) && is_array($aParams['condition']))
+            $o->setCustomCurrentCondition($aParams['condition']);
+
+        if ($o->isError)
+            return '';
+
+        if ($s = $o->processing())
+            return bx_is_api() ? [bx_api_get_block('browse', $s)] : $s;
+        else
+            return '';
+    }
+
+    /**
+     * Shows a list of profiles.
+     */
+    protected function _serviceBrowseQuick($aProfiles, $iStart = 0, $iLimit = 4, $aAdditionalParams = array())
+    {
+        // get paginate object
+        $oPaginate = new BxTemplPaginate(array(
+            'on_change_page' => "return !loadDynamicBlockAutoPaginate(this, '{start}', '{per_page}', " . bx_js_string(json_encode($aAdditionalParams)) . ");",
+            'num' => count($aProfiles),
+            'per_page' => $iLimit,
+            'start' => $iStart,
+        ));
+
+        // remove last item from connection array, because we've got one more item for pagination calculations only
+        if (count($aProfiles) > $iLimit)
+            array_pop($aProfiles);
+
+        // get profiles HTML
+        $s = '';
+        foreach ($aProfiles as $mixedProfile) {
+            $bProfile = is_array($mixedProfile);
+
+            $oProfile = BxDolProfile::getInstance($bProfile ? (int)$mixedProfile['id'] : (int)$mixedProfile);
+            if(!$oProfile)
+                continue;
+
+            $aUnitParams = array('template' => array('name' => 'unit', 'size' => 'thumb'));
+            if(BxDolModule::getInstance($oProfile->getModule()) instanceof BxBaseModGroupsModule)
+                $aUnitParams['template']['name'] = 'unit_wo_cover';
+
+            if($bProfile && is_array($mixedProfile['info']))
+                $aUnitParams['template']['vars'] = $mixedProfile['info'];
+
+            $s .= $oProfile->getUnit(0, $aUnitParams);
+        }
+
+        $aTmplVarsPaginate = [];
+        if($iStart || $oPaginate->getNum() > $iLimit)
+            $aTmplVarsPaginate = ['paginate' => $oPaginate->getSimplePaginate()];
+
+        return $this->_oTemplate->parseHtmlByName('browse_quick.html', array(
+            'code' => $s,
+            'bx_if:show_paginate' => [
+                'condition' => !empty($aTmplVarsPaginate),
+                'content' => $aTmplVarsPaginate
+            ]
+        ));
+    }
+
+    // ====== COMMON METHODS
+    public function onPublished($iContentId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if(!$aContentInfo)
+            return;
+
+        /*
+         * Process metas.
+         * Note. It's essential to process metas a the very end, 
+         * because all data related to an entry should be already
+         * processed and are ready to be passed to alert. 
+         */
+        $this->processMetasAdd($iContentId);
+
+        $this->alertAfterAdd($aContentInfo);
+    }
+
+    public function onFailed($iContentId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if(!$aContentInfo)
+            return;
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_general-failed '{module_name}', 'failed' - hook after content creation when publishing was failed
+         * - $unit_name - module name
+         * - $action - equals `failed`
+         * - $object_id - content id
+         * - $sender_id - content author profile id
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `object_author_id` - [int] content author profile id
+         * @hook @ref hook-bx_base_general-failed
+         */
+        bx_alert($this->getName(), 'failed', $iContentId, $aContentInfo[$CNF['FIELD_AUTHOR']], [
+            'object_author_id' => $aContentInfo[$CNF['FIELD_AUTHOR']]
+        ]);
+    }
+
+    public function onApprove($mixedContent)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!is_array($mixedContent))
+            $mixedContent = $this->_oDb->getContentInfoById((int)$mixedContent);
+
+        $this->alertAfterApprove($mixedContent);
+
+        $this->onPublished($mixedContent[$CNF['FIELD_ID']]);
+    }
+
+    public function onUpdateImage($iContentId, $sFiledName, $sFiledValue, $iProfileId = 0) {}
+
+    public function alertAfterAdd($aContentInfo) {}
+    
+    public function alertAfterEdit($aContentInfo) {}
+
+    public function alertAfterApprove($aContentInfo)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $iId = (int)$aContentInfo[$CNF['FIELD_ID']];
+
+        $sModule = $this->getName();
+        $aParams = $this->_alertParams($aContentInfo);
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_general-approved '{module_name}', 'approved' - hook when module content has been approved
+         * - $unit_name - module name
+         * - $action - equals `approved` 
+         * - $object_id - content id 
+         * - $sender_id - not used, always `false`
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `source` - [string] uniq string for particular content in the following format `{module}_{content}`
+         *      - `object_author_id` - [int] content author profile id
+         * @hook @ref hook-bx_base_general-approved
+         */
+        bx_alert($sModule, 'approved', $iId, false, $aParams);
+    }
+
+    /**
+     * Get array of params to be passed in Add/Edit Alert.
+     */
+    protected function _alertParams($aContentInfo)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        return [
+            'source' => $this->_oConfig->getName() . '_' . (int)$aContentInfo[$CNF['FIELD_ID']],
+
+            'object_author_id' => (int)$aContentInfo[$CNF['FIELD_AUTHOR']]
+        ];
+    }
+
+    public function processMetasAdd($iContentId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(empty($CNF['OBJECT_METATAGS'])) 
+            return false;
+
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+
+        $bFldStatus = !empty($CNF['FIELD_STATUS']);
+        $bFldStatusAdmin = !empty($CNF['FIELD_STATUS_ADMIN']);
+        $bContentInfo = $aContentInfo && (!$bFldStatus || ($bFldStatus && $aContentInfo[$CNF['FIELD_STATUS']] == 'active')) && (!$bFldStatusAdmin || ($bFldStatusAdmin && $aContentInfo[$CNF['FIELD_STATUS_ADMIN']] == 'active'));
+        if(!$bContentInfo)
+            return false;
+
+        $oMetatags = BxDolMetatags::getObjectInstance($CNF['OBJECT_METATAGS']);
+        $oMetatags->metaAddAuto($iContentId, $aContentInfo, $CNF, $CNF['OBJECT_FORM_ENTRY_DISPLAY_ADD']);
+
+        $sKey = 'FIELD_LOCATION';
+        if($oMetatags->locationsIsEnabled() && !empty($CNF[$sKey]) && !empty($aContentInfo[$CNF[$sKey]])) {
+            $aLocation = unserialize($aContentInfo[$CNF[$sKey]]);
+            if(!empty($aLocation) && is_array($aLocation))
+                call_user_func_array(array($oMetatags, 'locationsAdd'), array_merge(array($iContentId), array_values($aLocation)));
+        }
+
+        $sKey = 'FIELD_LABELS';
+        if($oMetatags->keywordsIsEnabled() && !empty($CNF[$sKey]) && !empty($aContentInfo[$CNF[$sKey]])) {
+            $aLabels = unserialize($aContentInfo[$CNF[$sKey]]);
+            if(!empty($aLabels) && is_array($aLabels))
+                foreach ($aLabels as $sLabel) {
+                    if(!preg_match("/(\pL[\pL\pN_]+)/u", $sLabel)) 
+                        continue;
+
+                    $oMetatags->keywordsAddOne($iContentId, $sLabel, false);
+                }
+        }
+        
+        return true;
+    }
+
+    public function processMetasEdit($iContentId, $oForm)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(empty($CNF['OBJECT_METATAGS']))
+            return false;
+
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+
+        $bFldStatus = isset($CNF['FIELD_STATUS']);
+        $bFldStatusAdmin = isset($CNF['FIELD_STATUS_ADMIN']);
+        $bContentInfo = $aContentInfo && (!$bFldStatus || ($bFldStatus && $aContentInfo[$CNF['FIELD_STATUS']] == 'active')) && (!$bFldStatusAdmin || ($bFldStatusAdmin && $aContentInfo[$CNF['FIELD_STATUS_ADMIN']] == 'active'));
+        if(!$bContentInfo)
+            return false;
+
+        $oMetatags = BxDolMetatags::getObjectInstance($CNF['OBJECT_METATAGS']);
+        $oMetatags->metaAddAuto($iContentId, $aContentInfo, $CNF, $CNF['OBJECT_FORM_ENTRY_DISPLAY_EDIT']);
+
+        $sKey = 'FIELD_LOCATION';
+        if($oMetatags->locationsIsEnabled() && !empty($CNF[$sKey]) && !empty($aContentInfo[$CNF[$sKey]])) {
+            $aLocation = unserialize($aContentInfo[$CNF[$sKey]]);
+            if(!empty($aLocation) && is_array($aLocation))
+                call_user_func_array(array($oMetatags, 'locationsAdd'), array_merge(array($iContentId), array_values($aLocation)));
+        }
+
+        $sKey = 'FIELD_LABELS';
+        if($oMetatags->keywordsIsEnabled() && !empty($CNF[$sKey]) && !empty($aContentInfo[$CNF[$sKey]])) {
+            $aLabels = unserialize($aContentInfo[$CNF[$sKey]]);
+            if(!empty($aLabels) && is_array($aLabels))
+                foreach ($aLabels as $sLabel) {
+                    if(!preg_match("/(\pL[\pL\pN_]+)/u", $sLabel))
+                        continue;
+
+                    $oMetatags->keywordsAddOne($iContentId, $sLabel, false);
+                }
+        }
+
+        return true;
+    }
+
+    public function addAttachLink($aValues, $sDisplay = false)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!$sDisplay)
+            $sDisplay = $CNF['OBJECT_FORM_ATTACH_LINK_DISPLAY_ADD'];
+
+        $oForm = BxDolForm::getObjectInstance($CNF['OBJECT_FORM_ATTACH_LINK'], $sDisplay, $this->_oTemplate);
+        if(!$oForm)
+            return array('message' => '_sys_txt_error_occured');
+
+        $oForm->aFormAttrs['method'] = BX_DOL_FORM_METHOD_SPECIFIC;
+        $oForm->aParams['csrf']['disable'] = true;
+        if(!empty($oForm->aParams['db']['submit_name'])) {
+            $sSubmitName = $oForm->aParams['db']['submit_name'];
+            if(!isset($oForm->aInputs[$sSubmitName])) {
+                if(isset($oForm->aInputs[$CNF['FIELD_ATTACH_LINK_CONTROLS']]))
+                    foreach($oForm->aInputs[$CNF['FIELD_ATTACH_LINK_CONTROLS']] as $mixedIndex => $aInput) {
+                        if(!is_numeric($mixedIndex) || empty($aInput['name']) || $aInput['name'] != $sSubmitName)
+                            continue;
+    
+                        $aValues[$sSubmitName] = $aInput['value'];
+                    }
+            }
+            else            
+                $aValues[$sSubmitName] = $oForm->aInputs[$sSubmitName]['value'];
+        }
+
+        $oForm->aInputs['url']['checker']['params']['preg'] = $this->_oConfig->getPregPattern('url');
+
+        $oForm->initChecker(array(), $aValues);
+        if(!$oForm->isSubmittedAndValid())
+            return array('message' => '_sys_txt_error_occured');
+
+        return $this->_addLink($oForm);
+    }
+
+    public function getFormAttachLink($iContentId = 0)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $oForm = BxDolForm::getObjectInstance($CNF['OBJECT_FORM_ATTACH_LINK'], $CNF['OBJECT_FORM_ATTACH_LINK_DISPLAY_ADD'], $this->_oTemplate);
+        $oForm->aFormAttrs['action'] = BX_DOL_URL_ROOT . $this->_oConfig->getBaseUri() . 'submit_attach_link_form/';
+        $oForm->aInputs[$CNF['FIELD_ATTACH_LINK_CONTENT_ID']]['value'] = $iContentId;
+        $oForm->aInputs[$CNF['FIELD_ATTACH_LINK_URL']]['checker']['params']['preg'] = $this->_oConfig->getPregPattern('url');
+
+        $oForm->initChecker();
+        if($oForm->isSubmittedAndValid())
+            return $this->_addLink($oForm);
+
+        return ['form' => $oForm->getCode(), 'form_id' => $oForm->id];
+    }
+
+    protected function _addLink(&$oForm)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $iUserId = $this->getUserId();
+
+        $iContentId = (int)$oForm->getCleanValue($CNF['FIELD_ATTACH_LINK_CONTENT_ID']);
+        $sLink = rtrim($oForm->getCleanValue('url'), '/');
+        $sHost = parse_url($sLink, PHP_URL_HOST);
+        if($sHost && is_private_ip(gethostbyname($sHost)))
+            return ['message' => _t('_sys_txt_error_occured')];
+
+        $aMatches = [];
+        preg_match($this->_oConfig->getPregPattern('url'), $sLink, $aMatches);
+        $sLink = (empty($aMatches[2]) ? 'http://' : '') . $aMatches[0];
+
+        $aSiteInfo = bx_get_site_info($sLink, [
+            'thumbnailUrl' => ['tag' => 'link', 'content_attr' => 'href'],
+            'OGImage' => ['name_attr' => 'property', 'name' => 'og:image'],
+        ]);
+
+        $sTitle = !empty($aSiteInfo['title']) ? $aSiteInfo['title'] : $sHost;
+        $sDescription = !empty($aSiteInfo['description']) ? $aSiteInfo['description'] : '';
+
+        $sMediaUrl = '';
+        if(!empty($aSiteInfo['thumbnailUrl']))
+            $sMediaUrl = $aSiteInfo['thumbnailUrl'];
+        else if(!empty($aSiteInfo['OGImage']))
+            $sMediaUrl = $aSiteInfo['OGImage'];
+
+        $iMediaId = 0;
+        $oStorage = null;
+        if(!empty($sMediaUrl)) {
+            $oStorage = BxDolStorage::getObjectInstance($CNF['OBJECT_STORAGE_PHOTOS']);
+
+            $iMediaId = $oStorage->storeFileFromUrl($sMediaUrl, true, $iUserId);
+        }
+
+        $iId = (int)$oForm->insert(['profile_id' => $iUserId, 'media_id' => $iMediaId, 'url' => $sLink, 'title' => $sTitle, 'text' => $sDescription, 'added' => time()]);
+        if(!empty($iId)) {
+            if(!empty($oStorage) && !empty($iMediaId))
+                $oStorage->afterUploadCleanup($iMediaId, $iUserId);
+
+            return [
+                'id' => $iId, 
+                $CNF['FIELD_ATTACH_LINK_CONTENT_ID'] => $iContentId, 
+                'url' => $sLink,
+                'item' => $this->_oTemplate->getAttachLinkItem($iUserId, $iId)
+            ];
+        }
+
+        return ['message' => _t('_bx_timeline_txt_err_cannot_perform_action')];
+    }
+
+    public function getEntryImageData($aContentInfo, $sField = 'FIELD_THUMB', $aTranscoders = array())
+    {
+        if(empty($aTranscoders))
+            $aTranscoders = array('OBJECT_TRANSCODER_COVER', 'OBJECT_IMAGES_TRANSCODER_COVER', 'OBJECT_IMAGES_TRANSCODER_GALLERY');
+
+        $CNF = &$this->_oConfig->CNF;
+        if(empty($CNF[$sField]) || empty($aContentInfo[$CNF[$sField]]) || empty($CNF['OBJECT_STORAGE']))
+            return false;
+
+        $iId = (int)$aContentInfo[$CNF[$sField]];
+        $aFile = BxDolStorage::getObjectInstance($CNF['OBJECT_STORAGE'])->getFile($iId);
+        if(!empty($aFile) && is_array($aFile) && strpos($aFile['mime_type'], 'svg') !== false)
+            return array('id' => $iId, 'object' => $CNF['OBJECT_STORAGE']);
+
+        foreach($aTranscoders as $sTranscoder)
+            if(!empty($CNF[$sTranscoder]))
+                return array('id' => $iId, 'transcoder' => $CNF[$sTranscoder]);
+
+        return array('id' => $iId, 'object' => $CNF['OBJECT_STORAGE']);
+    }
+
+    public function getEntryAllActions ($mixedContent = false, $aParams = [])
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!empty($mixedContent)) {
+            if(!is_array($mixedContent))
+                $mixedContent = array((int)$mixedContent, (method_exists($this->_oDb, 'getContentInfoById')) ? $this->_oDb->getContentInfoById((int)$mixedContent) : array());
+        }
+        else {
+            $mixedContent = $this->_getContent();
+            if($mixedContent === false)
+                return false;
+        }
+
+        list($iContentId, $aContentInfo) = $mixedContent;
+
+        $sObjectMenu = !empty($aParams['object_menu']) ? $aParams['object_menu'] : '';
+        if(empty($sObjectMenu) && !empty($CNF['OBJECT_MENU_ACTIONS_VIEW_ENTRY_ALL']))
+            $sObjectMenu = $CNF['OBJECT_MENU_ACTIONS_VIEW_ENTRY_ALL'];
+
+        if(empty($sObjectMenu))
+            return false;
+
+        $sEntryTitle = !empty($aParams['entry_title']) ? $aParams['entry_title'] : '';
+        if(empty($sEntryTitle) && !empty($CNF['FIELD_TITLE']) && !empty($aContentInfo[$CNF['FIELD_TITLE']]))
+            $sEntryTitle = $aContentInfo[$CNF['FIELD_TITLE']];
+
+        $sEntryUrl = !empty($aParams['entry_url']) ? $aParams['entry_url'] : '';
+        if(empty($sEntryUrl) && !empty($CNF['URI_VIEW_ENTRY']))
+            $sEntryUrl = bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $CNF['URI_VIEW_ENTRY'] . '&id=' . $iContentId));
+
+        $iEntryThumb = !empty($aParams['entry_thumb']) ? (int)$aParams['entry_thumb'] : 0;
+        if(empty($iEntryThumb) && !empty($CNF['FIELD_THUMB']) && !empty($aContentInfo[$CNF['FIELD_THUMB']]))
+            $iEntryThumb = (int)$aContentInfo[$CNF['FIELD_THUMB']];
+
+        $sObjectStorage = !empty($aParams['object_storage']) ? $aParams['object_storage'] : false;
+        if(empty($sObjectStorage) && !empty($CNF['OBJECT_STORAGE']))
+            $sObjectStorage = $CNF['OBJECT_STORAGE'];
+
+        $sObjectTranscoder = !empty($aParams['object_transcoder']) ? $aParams['object_transcoder'] : false;
+
+        $aMarkers = [
+            'id' => $iContentId,
+            'module' => $this->_oConfig->getName(),
+            'title' => !empty($sEntryTitle) ? $sEntryTitle : '',
+            'url' => !empty($sEntryUrl) ? $sEntryUrl : '',
+            'img_url' => ''
+        ];
+
+        if(!empty($iEntryThumb)) {
+            if(!empty($sObjectTranscoder))
+                $o = BxDolTranscoder::getObjectInstance($sObjectTranscoder);
+            else if(!empty($sObjectStorage))
+                $o = BxDolStorage::getObjectInstance($sObjectStorage);
+
+            $sImageUrl = $o ? $o->getFileUrlById($iEntryThumb) : '';
+            if(!empty($sImageUrl))
+                $aMarkers['img_url'] = $sImageUrl;
+        }
+
+        $oActions = BxDolMenu::getObjectInstance($sObjectMenu, $this->_oTemplate);
+        if(!$oActions)
+            return false;
+
+        $oActions->setContentId($iContentId);
+        $oActions->addMarkers($aMarkers);
+
+        return $oActions->{bx_is_api() ? 'getCodeAPI' : 'getCode'}();
+    }
+
+    public function getProfileId()
+    {
+    	return bx_get_logged_profile_id();
+    }
+
+    public function getProfileInfo($iUserId = false)
+    {
+        $oProfile = $this->getObjectUser($iUserId);
+
+        $oAccount = null;
+        if($oProfile && !($oProfile instanceof BxDolProfileUndefined) && !($oProfile instanceof BxDolProfileAnonymous))
+            $oAccount = $oProfile->getAccountObject();
+        $bAccount = !empty($oAccount);
+
+        if(!$bAccount)
+            $oProfile = BxDolProfileUndefined::getInstance();
+
+        return array(
+            'id' => $oProfile->id(),
+            'name' => $oProfile->getDisplayName(),
+            'email' => $bAccount ? $oAccount->getEmail() : '',
+            'link' => $oProfile->getUrl(),
+            'icon' => $oProfile->getIcon(),
+            'thumb' => $oProfile->getThumb(),
+            'avatar' => $oProfile->getAvatar(),
+            'active' => $oProfile->isActive(),
+        );
+    }
+
+    public function getObjectUser($iUserId = false)
+    {
+    	bx_import('BxDolProfile');
+        return BxDolProfile::getInstanceMagic($iUserId);
+    }
+
+    public function getObjectFavorite($sSystem = '', $iId = 0)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        if(empty($sSystem) && !empty($CNF['OBJECT_FAVORITES']))
+            $sSystem = $CNF['OBJECT_FAVORITES'];
+
+        if(empty($sSystem))
+            return false;
+
+        $oFavorite = BxDolFavorite::getObjectInstance($sSystem, $iId, true, $this->_oTemplate);
+        if(!$oFavorite || !$oFavorite->isEnabled())
+            return false;
+
+        return $oFavorite;
+    }
+
+	public function getUserId()
+    {
+        return isLogged() ? bx_get_logged_profile_id() : 0;
+    }
+
+    public function getUserIp()
+    {
+        return getVisitorIP();
+    }
+    
+    public function getUserInfo($iUserId = 0)
+    {
+        $oProfile = BxDolProfile::getInstanceMagic($iUserId);
+
+        return array(
+            $oProfile->getDisplayName(),
+            $oProfile->getUrl(),
+            $oProfile->getThumb(),
+            $oProfile->getUnit(),
+            $oProfile->getUnit(0, array('template' => 'unit_wo_info'))
+        );
+    }
+    
+    public function getUserInfoWithBadges($iUserId = 0)
+    {
+        $oProfile = BxDolProfile::getInstanceMagic($iUserId);
+
+        return array(
+            $oProfile->getDisplayName(),
+            $oProfile->getUrl(),
+            $oProfile->getThumb(),
+            $oProfile->getUnit(),
+            $oProfile->getUnit(0, array('template' => 'unit_wo_info')),
+            $oProfile->getBadges()
+        );
+    }
+
+    public function isMenuItemVisible($sObject, &$aItem, &$aContentInfo)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        // default visible settings
+        if(!BxDolAcl::getInstance()->isMemberLevelInSet($aItem['visible_for_levels']))
+            return false;
+
+        if (!empty($aItem['visibility_custom'])) {
+            $oMenu = BxDolMenu::getObjectInstance($sObject);
+            if ($oMenu && !BxDolService::callSerialized($aItem['visibility_custom'], $oMenu->getMarkers()))
+                return false;
+        }
+        
+        // get custom function name to check menu item visibility
+        $sFuncCheckAccess = false;
+        if(isset($CNF['MENU_ITEM_TO_METHOD'][$sObject][$aItem['name']]))
+            $sFuncCheckAccess = $CNF['MENU_ITEM_TO_METHOD'][$sObject][$aItem['name']];
+
+        // check custom visibility settings defined in module config class
+        if(!isset($aContentInfo))
+            $aContentInfo = array();
+
+        if($sFuncCheckAccess && CHECK_ACTION_RESULT_ALLOWED !== call_user_func_array(array($this, $sFuncCheckAccess), array(&$aContentInfo)))
+            return false;
+
+        return true;
+    }
+
+    public function _isModerator ($isPerformAction = false)
+    {
+        return $this->_isModeratorForProfile($isPerformAction, $this->_iProfileId);
+    }
+
+    public function _isModeratorForProfile($isPerformAction = false, $iProfileId = false)
+    {
+        return CHECK_ACTION_RESULT_ALLOWED === $this->checkAllowedEditAnyEntryForProfile ($isPerformAction, $iProfileId);
+    }
+
+    public function _isAdministrator ($isPerformAction = false)
+    {
+        return $this->_isAdministratorForProfile($isPerformAction, $this->_iProfileId);
+    }
+
+    public function _isAdministratorForProfile($isPerformAction = false, $iProfileId = false)
+    {
+        return CHECK_ACTION_RESULT_ALLOWED === $this->checkAllowedDeleteAnyEntryForProfile ($isPerformAction, $iProfileId);
+    }
+
+    public function _prepareAuditParams($aContentInfo, $bIsSaveData = true, $aOverrideAuditParams  = array())
+    {
+        $CNF = &$this->_oConfig->CNF;
+        
+        $iContextId = isset($CNF['FIELD_ALLOW_VIEW_TO']) && (!empty($aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']]) && (int)$aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']] < 0) ? - $aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']] : 0;
+        
+        $AuditParams = array(
+            'content_title' => (isset($CNF['FIELD_TITLE']) && isset($aContentInfo[$CNF['FIELD_TITLE']])) ? $aContentInfo[$CNF['FIELD_TITLE']] : '',
+            'context_profile_id' => $iContextId,
+            'content_info_object' =>  isset($CNF['OBJECT_CONTENT_INFO']) ? $CNF['OBJECT_CONTENT_INFO'] : '',
+            'data' => ($bIsSaveData ? $aContentInfo : array())
+        );
+        if ($iContextId > 0)
+            $AuditParams['context_profile_title'] = BxDolProfile::getInstance($iContextId)->getDisplayName();
+        
+        $AuditParams = array_merge($AuditParams, $aOverrideAuditParams);
+        
+        return $AuditParams;
+    }
+    
+    public function _getFavoriteListUrl ($iListId, $iProfileId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        return bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $CNF['URI_FAVORITES_LIST'] . '&profile_id=' . $iProfileId . '&list_id=' . $iListId));
+    }
+
+    public function getDataAPI($aData, $aParams = [])
+    {
+        $aDataApi = $this->decodeDataAPI($aData, $aParams);
+
+        $sModule = $this->getName();
+        $aExtras = [
+            'module' => $sModule,
+            'data' => $aData,
+            'params' => $aParams,
+            'data_api' => &$aDataApi,
+        ];
+
+        /**
+         * @hooks
+         * @hookdef hook-system-decode_data_api 'system', 'decode_data_api' - hook to override content data prepared for sending in API response
+         * - $unit_name - equals `system`
+         * - $action - equals `decode_data_api`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `module` - [string] module name
+         *      - `data` - [array] content info array as key&value pairs
+         *      - `params` - [array] params array as key&value pairs
+         *      - `data_api` - [array] by ref, content data prepared for sending in API response, can be overridden in hook processing
+         * @hook @ref hook-system-decode_data_api
+         */
+        bx_alert('system', 'decode_data_api', 0, 0, $aExtras);
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_general-decode_data_api '{module_name}', 'decode_data_api' - hook to override content data prepared for sending in API response
+         * It's equivalent to @ref hook-system-decode_data_api
+         * @hook @ref hook-bx_base_general-decode_data_api
+         */
+        bx_alert($sModule, 'decode_data_api', 0, 0, $aExtras);
+
+        return $aDataApi;
+    }
+
+    public function decodeDataAPI($aData, $aParams = [])
+    {
+        return $aData;
+    }
+
+    // ====== PROTECTED METHODS
+
+    protected function _serviceEntityForm ($sFormMethod, $iContentId = 0, $sDisplay = false, $sCheckFunction = false, $bErrorMsg = true)
+    {
+        $iContentId = $this->_getContent($iContentId, false);
+        if($iContentId === false)
+            return false;
+
+        $oFormsHelper = $this->getFormsHelper();
+        return $oFormsHelper->$sFormMethod((int)$iContentId, $sDisplay, $sCheckFunction, $bErrorMsg);
+    }
+
+    protected function _serviceTemplateFunc ($sFunc, $iContentId, $sFuncGetContent = 'getContentInfoById')
+    {
+        return $this->_serviceTemplateFuncEx($sFunc, $iContentId, array('function_get_content' => $sFuncGetContent));
+    }
+    
+    protected function _serviceTemplateFuncEx ($sFunc, $iContentId, $aParams = array())
+    {
+        if (!isset($aParams['function_get_content']))
+            $aParams['function_get_content'] = 'getContentInfoById';
+        
+        $mixedContent = $this->_getContent($iContentId, $aParams['function_get_content']);
+        if($mixedContent === false)
+            return false;
+
+        list($iContentId, $aContentInfo) = $mixedContent;
+
+        unset($aParams['function_get_content']);
+        
+        if (!empty($aParams))
+            return $this->_oTemplate->$sFunc($aContentInfo, $aParams);
+        else
+            return $this->_oTemplate->$sFunc($aContentInfo);
+    }
+
+    protected function _getApproveForm($iContentId, $aContentInfo)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $sForm = 'sys_manage';
+        if(!empty($CNF['OBJECT_FORM_MANAGE']))
+            $sForm = $CNF['OBJECT_FORM_MANAGE'];
+
+        $sFormDisplay = 'sys_manage_approve';
+        if(!empty($CNF['OBJECT_FORM_MANAGE_APPROVE']))
+            $sFormDisplay = $CNF['OBJECT_FORM_MANAGE_APPROVE'];
+
+        $oForm = BxTemplFormView::getObjectInstance($sForm, $sFormDisplay);
+        $oForm->aFormAttrs['action'] = BX_DOL_URL_ROOT . $this->_oConfig->getBaseUri() . 'approve';
+        $oForm->aInputs['content_id']['value'] = $iContentId;
+        foreach($oForm->aInputs['controls'] as $iKey => $mixedValue)
+            if(is_numeric($iKey) && isset($mixedValue['name']) && $mixedValue['name'] == 'do_submit')
+                $oForm->aInputs['controls'][$iKey]['value'] = _t('_sys_form_manage_input_do_submit_approve');
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_general-get_approve_form '{module_name}', 'get_approve_form' - hook to override approve content form
+         * - $unit_name - module name
+         * - $action - equals `get_approve_form`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `content_id` - [int] content id
+         *      - `content_info` - [array] content info array as key&value pairs
+         *      - `override_result` - [object] by ref, an instance of approve content form, @see BxDolForm, can be overridden in hook processing
+         * @hook @ref hook-bx_base_general-get_approve_form
+         */
+        bx_alert($this->_oConfig->getName(), 'get_approve_form', 0, 0, [
+            'content_id' => $iContentId,
+            'content_info' => $aContentInfo,
+            'override_result' => &$oForm
+        ]);
+
+        return $oForm;
+    }
+
+    protected function _sendApproveMessage($iContentId, $aContentInfo, &$oForm)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $sETemplate = 't_ManageApprove';
+        if(!empty($CNF['ETEMPLATE_MANAGE_APPROVE']))
+            $sETemplate = $CNF['ETEMPLATE_MANAGE_APPROVE'];
+
+        $aEParams = array(
+            'content_title' => !empty($CNF['FIELD_TITLE']) && !empty($aContentInfo[$CNF['FIELD_TITLE']]) ? $aContentInfo[$CNF['FIELD_TITLE']] : '',
+            'notes' => $oForm->getCleanValue('notes'),
+        );
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_general-get_approve_message '{module_name}', 'get_approve_form' - hook to override approve content message
+         * - $unit_name - module name
+         * - $action - equals `get_approve_message`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `content_id` - [int] content id
+         *      - `content_info` - [array] content info array as key&value pairs
+         *      - `form` - [object] an instance of approve content form
+         *      - `email_template` - [string] by ref, email template name, @see BxDolEmailTemplates, can be overridden in hook processing
+         *      - `email_params` - [array] by ref, email template params, can be overridden in hook processing
+         * @hook @ref hook-bx_base_general-get_approve_message
+         */
+        bx_alert($this->_oConfig->getName(), 'get_approve_message', 0, 0, [
+            'content_id' => $iContentId,
+            'content_info' => $aContentInfo,
+            'form' => $oForm,
+            'email_template' => &$sETemplate,
+            'email_params' => &$aEParams
+        ]);
+
+        return sendMailTemplate($sETemplate, 0, $aContentInfo[$CNF['FIELD_AUTHOR']], $aEParams);
+    }
+
+    protected function _processModerationNotifications($aContentInfo)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(empty($CNF['PARAM_AUTO_APPROVE']) || getParam($CNF['PARAM_AUTO_APPROVE']) == 'on')
+            return;
+
+        $aRecipients = [];
+        if(getParam('sys_notify_to_approve_by_role') == 'on' && bx_srv('system', 'is_module_content', [$this->_oConfig->getName()]) && !empty($CNF['FIELD_ALLOW_VIEW_TO']) && (int)$aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']] < 0) {
+            $iContextProfileId = abs((int)$aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']]);
+            $oContextProfile = BxDolProfile::getInstance($iContextProfileId);
+
+            $aRecipients = bx_srv($oContextProfile->getModule(), 'get_admins_to_manage_content', [$iContextProfileId]);
+        }
+
+        $sModule = $this->getName();
+
+        if(empty($aRecipients))
+            $aRecipients = BxDolAclQuery::getInstance()->getProfilesByAction([
+                MEMBERSHIP_ACTION_EDIT_ANY_ENTRY, 
+                MEMBERSHIP_ACTION_DELETE_ANY_ENTRY
+            ], [
+                'module' => $sModule, 
+                'ids_only' => true
+            ]);
+
+        if(empty($aRecipients))
+            return;
+
+        foreach($aRecipients as $iRecipientId)
+            /**
+             * @hooks
+             * @hookdef hook-bx_base_general-pending_approval '{module_name}', 'pending_approval' - hook to notify admins/moderators about new pending approval content
+             * - $unit_name - module name
+             * - $action - equals `pending_approval`
+             * - $object_id - content id
+             * - $sender_id - content author profile id
+             * - $extra_params - array of additional params with the following array keys:
+             *      - `object_author_id` - [int] admin/moderator profile id
+             * @hook @ref hook-bx_base_general-pending_approval
+             */
+            bx_alert($sModule, 'pending_approval', $aContentInfo[$CNF['FIELD_ID']], $aContentInfo[$CNF['FIELD_AUTHOR']], [
+                'object_author_id' => (int)$iRecipientId
+            ]);
+    }
+
+    protected function _rss ($aArgs, $sClass = 'SearchResult')
+    {
+        $sMode = array_shift($aArgs);
+
+        if (CHECK_ACTION_RESULT_ALLOWED !== ($sMsg = $this->checkAllowedBrowse())) {
+            $this->_oTemplate->displayAccessDenied ($sMsg);
+            exit;
+        }
+
+        $aParams = $this->_buildRssParams($sMode, $aArgs);
+
+        bx_import ($sClass, $this->_aModule);
+        $sClass = $this->_aModule['class_prefix'] . $sClass;
+        $o = new $sClass($sMode, $aParams);
+
+        if ($o->isError)
+            $this->_oTemplate->displayPageNotFound ();
+        else
+            $o->outputRSS();
+
+        exit;
+    }
+
+    protected function _getContent($iContentId = 0, $sFuncGetContent = true)
+    {
+        if(!$iContentId)
+            $iContentId = bx_process_input(bx_get('id'), BX_DATA_INT);
+
+        if(!$iContentId)
+            return false;
+
+        if($sFuncGetContent === true)
+            $sFuncGetContent = 'getContentInfoById';
+
+        if(empty($sFuncGetContent) || !method_exists($this->_oDb, $sFuncGetContent))
+            return $iContentId;
+
+        $aContentInfo = $this->_oDb->$sFuncGetContent($iContentId);
+        if(!$aContentInfo)
+            return false;
+
+        return array($iContentId, $aContentInfo);
+    }
+
+    protected function _getContentForTimelinePost($aEvent, $aContentInfo, $aBrowseParams = array())
+    {
+    	$CNF = &$this->_oConfig->CNF;
+
+    	$sUrl = bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $CNF['URI_VIEW_ENTRY'] . '&id=' . $aContentInfo[$CNF['FIELD_ID']]));
+
+    	//--- Image(s)
+        $aImages = $this->_getImagesForTimelinePost($aEvent, $aContentInfo, $sUrl, $aBrowseParams);
+        $aImagesAttach = $this->_getImagesForTimelinePostAttach($aEvent, $aContentInfo, $sUrl, $aBrowseParams);
+
+        //--- Video(s)
+        $aVideos = $this->_getVideosForTimelinePost($aEvent, $aContentInfo, $sUrl, $aBrowseParams);
+        $aVideosAttach = $this->_getVideosForTimelinePostAttach($aEvent, $aContentInfo, $sUrl, $aBrowseParams);
+
+        //--- Files(s)
+        $aFiles = $this->_getFilesForTimelinePost($aEvent, $aContentInfo, $sUrl, $aBrowseParams);
+        $aFilesAttach = $this->_getFilesForTimelinePostAttach($aEvent, $aContentInfo, $sUrl, $aBrowseParams);
+
+        if($this->_bIsApi && getParam('sys_api_extended_units') != 'on') {
+            $sTitle = '';
+            if(isset($CNF['FIELD_TITLE']) && isset($aContentInfo[$CNF['FIELD_TITLE']]))
+                $sTitle = $aContentInfo[$CNF['FIELD_TITLE']];
+            else if(isset($CNF['FIELD_TEXT']) && isset($aContentInfo[$CNF['FIELD_TEXT']]))
+                $sTitle = strmaxtextlen($aContentInfo[$CNF['FIELD_TEXT']], 20, '...');
+
+            $sAbstract = isset($CNF['FIELD_ABSTRACT']) && isset($aContentInfo[$CNF['FIELD_ABSTRACT']]) ? $aContentInfo[$CNF['FIELD_ABSTRACT']] : '';
+
+            $sText = isset($CNF['FIELD_TEXT']) && isset($aContentInfo[$CNF['FIELD_TEXT']]) ? $aContentInfo[$CNF['FIELD_TEXT']] : '';
+            $sText = BxTemplFunctions::getInstance()->getStringWithLimitedLength(strip_tags($sText), 240);
+
+            $aResults = [
+                'url' => $sUrl,
+                'title' => $sTitle,
+                'abstract' => $sAbstract,
+                'text' => $sText
+            ];
+
+            if(!empty($aImages) && is_array($aImages))
+                $aResults['images'] = $aImages;
+            if(!empty($aVideos) && is_array($aVideos))
+                $aResults['videos'] = $aVideos;
+            if(!empty($aFiles) && is_array($aFiles))
+                $aResults['files'] = $aFiles;
+
+            return $aResults;
+        }
+
+        //--- Title
+        $sTitle = '';
+        if(isset($CNF['FIELD_TITLE']) && isset($aContentInfo[$CNF['FIELD_TITLE']]))
+            $sTitle = $aContentInfo[$CNF['FIELD_TITLE']];
+        else if(isset($CNF['FIELD_TEXT']) && isset($aContentInfo[$CNF['FIELD_TEXT']]))
+            $sTitle = strmaxtextlen($aContentInfo[$CNF['FIELD_TEXT']], 20, '...');
+
+        //--- Text
+        $sText = '';
+        if(isset($CNF['FIELD_ABSTRACT']) && isset($aContentInfo[$CNF['FIELD_ABSTRACT']]))
+            $sText = $aContentInfo[$CNF['FIELD_ABSTRACT']];
+        if(empty($sText) && isset($CNF['FIELD_TEXT']) && isset($aContentInfo[$CNF['FIELD_TEXT']]))
+            $sText = $aContentInfo[$CNF['FIELD_TEXT']];
+
+        if(!empty($CNF['OBJECT_METATAGS']) && is_string($sText)) {
+            $oMetatags = BxDolMetatags::getObjectInstance($CNF['OBJECT_METATAGS']);
+            $sText = $oMetatags->metaParse($aContentInfo[$CNF['FIELD_ID']], $sText);
+        }
+
+        $sCategory = '';
+        if (isset($CNF['OBJECT_CATEGORY']) && isset($CNF['OBJECT_CATEGORY']) && isset($aContentInfo[$CNF['FIELD_CATEGORY']])){
+            $oCategory = BxDolCategory::getObjectInstance($CNF['OBJECT_CATEGORY']);
+            if ($oCategory)
+                $sCategory = $oCategory->getCategoryTitle($aContentInfo[$CNF['FIELD_CATEGORY']]);
+        } 
+
+        $aResult = [
+            'title' => $sTitle,
+            'text' => $sText,
+            'images' => $aImages,
+            'images_attach' => $aImagesAttach,
+            'videos' => $aVideos,
+            'videos_attach' => $aVideosAttach,
+            'files' => $aFiles,
+            'files_attach' => $aFilesAttach
+        ];
+
+        return $this->_bIsApi ? $aResult : array_merge($aResult, [
+            'sample' => isset($CNF['T']['txt_sample_single_with_article']) ? $CNF['T']['txt_sample_single_with_article'] : $CNF['T']['txt_sample_single'],
+            'sample_wo_article' => $CNF['T']['txt_sample_single'],
+            'sample_action' => isset($CNF['T']['txt_sample_action']) ? $CNF['T']['txt_sample_action'] : '',
+            'url' => $sUrl,
+            'category' => $sCategory,
+        ]);
+    }
+
+    protected function _getImagesForTimelinePost($aEvent, $aContentInfo, $sUrl, $aBrowseParams = array())
+    {
+        $CNF = &$this->_oConfig->CNF;
+        if (bx_is_api()){
+            $aResult = [];
+            if(isset($CNF['FIELD_COVER']) && !empty($aContentInfo[$CNF['FIELD_COVER']])) {
+                $aResult[] = bx_api_get_image($CNF['OBJECT_STORAGE'], (int)$aContentInfo[$CNF['FIELD_COVER']]);
+            }
+            
+            if(isset($CNF['FIELD_THUMB']) && !empty($aContentInfo[$CNF['FIELD_THUMB']])) {
+                $aResult[] = bx_api_get_image($CNF['OBJECT_STORAGE'], (int)$aContentInfo[$CNF['FIELD_THUMB']]);
+            }
+            return $aResult;
+        }
+        else{
+            $iImageId = 0;
+            $sImageSm = $sImageMd = $sImageXl = '';
+            if(isset($CNF['FIELD_COVER']) && !empty($aContentInfo[$CNF['FIELD_COVER']])) {
+                $iImageId = (int)$aContentInfo[$CNF['FIELD_COVER']];
+                $sImageSm = $this->_oConfig->getImageUrl($iImageId, ['OBJECT_IMAGES_TRANSCODER_MINIATURE', 'OBJECT_IMAGES_TRANSCODER_THUMB']);
+                $sImageMd = $this->_oConfig->getImageUrl($iImageId, ['OBJECT_IMAGES_TRANSCODER_GALLERY', 'OBJECT_IMAGES_TRANSCODER_THUMB']);
+                $sImageXl = $this->_oConfig->getImageUrl($iImageId, ['OBJECT_IMAGES_TRANSCODER_COVER']);
+            }
+
+            if($sImageMd == '' && isset($CNF['FIELD_THUMB']) && !empty($aContentInfo[$CNF['FIELD_THUMB']])) {
+                $iImageId = (int)$aContentInfo[$CNF['FIELD_THUMB']];
+                $sImageSm = $this->_oConfig->getImageUrl($iImageId, ['OBJECT_IMAGES_TRANSCODER_MINIATURE', 'OBJECT_IMAGES_TRANSCODER_THUMB']);
+                $sImageMd = $this->_oConfig->getImageUrl($iImageId, ['OBJECT_IMAGES_TRANSCODER_GALLERY', 'OBJECT_IMAGES_TRANSCODER_THUMB']);
+                $sImageXl = $this->_oConfig->getImageUrl($iImageId, ['OBJECT_IMAGES_TRANSCODER_COVER']);
+            }
+
+            if(empty($sImageMd))
+                return [];
+
+            if($sImageSm == '')
+                $sImageSm = $sImageMd;
+
+            if($sImageXl == '')
+                $sImageXl = $sImageMd;
+
+            return [[
+                'id' => $iImageId, 
+                'url' => $sUrl, 
+                'src' => $sImageMd, 
+                'src_small' => $sImageSm, 
+                'src_medium' => $sImageMd, 
+                'src_orig' => $sImageXl
+            ]];
+        }
+    }
+
+    protected function _getImagesForTimelinePostAttach($aEvent, $aContentInfo, $sUrl, $aBrowseParams = array())
+    {
+        return array();
+    }
+
+    protected function _getVideosForTimelinePost($aEvent, $aContentInfo, $sUrl, $aBrowseParams = array())
+    {
+        return array();
+    }
+
+    protected function _getVideosForTimelinePostAttach($aEvent, $aContentInfo, $sUrl, $aBrowseParams = array())
+    {
+        return array();
+    }
+
+    protected function _getFilesForTimelinePost($aEvent, $aContentInfo, $sUrl, $aBrowseParams = array())
+    {
+        return array();
+    }
+
+    protected function _getFilesForTimelinePostAttach($aEvent, $aContentInfo, $sUrl, $aBrowseParams = array())
+    {
+        return array();
+    }
+
+    protected function _entityComments ($sObject, $iId = 0)
+    {
+        if (!$iId)
+            $iId = bx_process_input(bx_get('id'), BX_DATA_INT);
+
+        if (!$iId)
+            return false;
+
+        $oCmts = BxDolCmts::getObjectInstance($sObject, $iId);
+        if (!$oCmts || !$oCmts->isEnabled())
+            return false;
+        
+        if (bx_is_api()){
+            return [bx_srv('system', 'get_data_api', [['module' => $sObject, 'object_id' => $iId]], 'TemplCmtsServices')];
+        }
+        return $oCmts->getCommentsBlock([], ['in_designbox' => false, 'show_empty' => false]);
+    }
+
+    protected function _getFields($iContentId)
+    {
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if(empty($aContentInfo))
+            return array();
+
+        return BxDolContentInfo::formatFields($aContentInfo);
+    }
+
+    protected function _getFieldValue($sField, $iContentId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        if(empty($CNF[$sField]))
+            return false;
+
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if(empty($aContentInfo) || empty($aContentInfo[$CNF[$sField]]))
+            return false;
+
+        return $aContentInfo[$CNF[$sField]];
+    }
+
+    protected function _getFieldValueThumb($sField, $iContentId, $sTranscoder = '') 
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(empty($sTranscoder) || empty($CNF[$sField]))
+            return false;
+
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if(empty($aContentInfo) || empty($aContentInfo[$CNF[$sField]]))
+            return false;
+
+        $oImagesTranscoder = BxDolTranscoderImage::getObjectInstance($sTranscoder);
+        if(!$oImagesTranscoder)
+            return false;
+
+        return $oImagesTranscoder->getFileUrl($aContentInfo[$CNF[$sField]]);
+    }
+
+	protected function _prepareResponse($aResponse, $bAsJson = false, $aAdditional = array())
+    {
+    	if(!$bAsJson)
+    		return $aResponse;
+
+		if(!empty($aAdditional) && is_array($aAdditional))
+			$aResponse = array_merge($aResponse, $aAdditional);
+
+		echoJson($aResponse);
+		exit;
+    }  
+    
+    /**
+     * Blocks for lables tree browsing private functions
+     */
+    
+    private function _getLablesBreadcrumbsArray($iLabelId, &$aBreadcrumbs)
+	{
+		$oLabel = BxDolLabel::getInstance();
+		$aLabel = $oLabel->getLabels(array('type' => 'id', 'id' => $iLabelId));
+		if ($aLabel){
+            $aBreadcrumbs[$aLabel['id']] = array('id' => $aLabel['id'], 'value' => $aLabel['value']);
+			$this->_getLablesBreadcrumbsArray($aLabel['parent'], $aBreadcrumbs);
+		}
+	}
+    
+    private function _getLablesBrowseUrl($iLabelId)
+	{
+        list($sPageLink, $aPageParams) = bx_get_base_url_inline();
+		return BxDolPermalinks::getInstance()->permalink(bx_append_url_params($sPageLink, array_merge($aPageParams, array('label' => $iLabelId))));
+	}
+    
+    private function _getLablesTreeLevel($aBreadcrumbs, $iParent = 0)
+	{
+		$oLabel = BxDolLabel::getInstance();
+		$aLabelsOutput = array();
+        $aLabels = $oLabel->getLabels(array('type' => 'parent', 'parent' => $iParent));
+        if (count($aLabels) > 0){
+			foreach($aLabels as $aLabel) {
+				$sChild = $this->_getLablesTreeLevel($aBreadcrumbs, $aLabel['id']);
+				$bChildPresent = $sChild != '' ? true : false;
+				$bIsOpen = array_key_exists($aLabel['id'], $aBreadcrumbs) ? true : false;
+				if (!$bIsOpen){
+					$sChild = '';
+				}
+				$aLabelsOutput[] = array(
+					'value' => $aLabel['value'], 
+					'url' => $this->_getLablesBrowseUrl($aLabel['id']),
+					'child' => $sChild,
+					
+					'selected' => bx_process_input(bx_get('label'), BX_DATA_INT) == $aLabel['id'] ? 'selected' : ''.$aLabel['id'], 
+					'bx_if:open' => array(
+						'condition' => $bChildPresent && $bIsOpen,
+						'content' => array()
+					),
+					'bx_if:can_open' => array(
+						'condition' => $bChildPresent && !$bIsOpen,
+						'content' => array()
+					)
+				);
+			}
+			return $this->_oTemplate->parseHtmlByName('labels_tree.html', 
+				array(
+					'bx_repeat:items' => $aLabelsOutput,
+					'parent' => $iParent,
+				)
+			);
+		}
+		return '';
+	}
+}
+
+/** @} */

+ 2549 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/modules/base/groups/classes/BxBaseModGroupsModule.php

@@ -0,0 +1,2549 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    BaseGroups Base classes for groups modules
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+define('BX_BASE_MOD_GROUPS_MMODE_MULTI_ROLES', 'multi_roles');
+
+define('BX_BASE_MOD_GROUPS_ROLE_COMMON', 0);
+define('BX_BASE_MOD_GROUPS_ROLE_ADMINISTRATOR', 1);
+define('BX_BASE_MOD_GROUPS_ROLE_MODERATOR', 2);
+
+define('BX_BASE_MOD_GROUPS_ACTION_EDIT', 'edit');
+define('BX_BASE_MOD_GROUPS_ACTION_CHANGE_SETTINGS', 'change_settings');
+define('BX_BASE_MOD_GROUPS_ACTION_CHANGE_COVER', 'change_cover');
+define('BX_BASE_MOD_GROUPS_ACTION_INVITE', 'invite');
+define('BX_BASE_MOD_GROUPS_ACTION_MANAGE_FANS', 'manage_fans');
+define('BX_BASE_MOD_GROUPS_ACTION_MANAGE_ROLES', 'manage_roles');
+define('BX_BASE_MOD_GROUPS_ACTION_DELETE', 'delete');
+define('BX_BASE_MOD_GROUPS_ACTION_EDIT_CONTENT', 'edit_any');
+define('BX_BASE_MOD_GROUPS_ACTION_DELETE_CONTENT', 'delete_any');
+define('BX_BASE_MOD_GROUPS_ACTION_TIMELINE_POST_PIN', 'pin'); //for timeline posts only
+
+define('BX_BASE_MOD_GROUPS_PERIOD_UNIT_DAY', 'day');
+define('BX_BASE_MOD_GROUPS_PERIOD_UNIT_WEEK', 'week');
+define('BX_BASE_MOD_GROUPS_PERIOD_UNIT_MONTH', 'month');
+define('BX_BASE_MOD_GROUPS_PERIOD_UNIT_YEAR', 'year');
+
+/**
+ * Groups profiles module.
+ */
+class BxBaseModGroupsModule extends BxBaseModProfileModule
+{
+    function __construct(&$aModule)
+    {
+        parent::__construct($aModule);
+
+        $CNF = &$this->_oConfig->CNF;
+
+        if(isset($CNF['FIELD_PUBLISHED']))
+            $this->_aSearchableNamesExcept = array_merge($this->_aSearchableNamesExcept, array(
+                $CNF['FIELD_PUBLISHED'],
+            ));
+    }
+
+    /**
+     * Get possible recipients for start conversation form
+     */
+    public function actionAjaxGetInitialMembers ()
+    {
+        $sTerm = bx_get('term');
+
+        $a = BxDolService::call('system', 'profiles_search', [$sTerm, ['module' => $this->_oConfig->getName()]], 'TemplServiceProfiles');
+
+        header('Content-Type:text/javascript; charset=utf-8');
+        echo(json_encode($a));
+    }
+    
+    public function serviceGetInitialMembers ($sParams)
+    {
+        $aOptions = json_decode($sParams, true);
+        if (!$sParams || !isset($aOptions['term']))
+            return [];
+        
+        $a = BxDolService::call('system', 'profiles_search', [$aOptions['term'], ['module' => $this->_oConfig->getName()]], 'TemplServiceProfiles');
+
+        return $a;
+    }
+    
+    /**
+     * Process Process Invitation
+     */
+    public function actionProcessInvite ($sKey, $iGroupProfileId, $bAccept)
+    {
+        $aData = $this->_oDb->getInviteByKey($sKey, $iGroupProfileId);
+        if (isset($aData['invited_profile_id'])){
+            $CNF = &$this->_oConfig->CNF;
+            if (!isset($CNF['OBJECT_CONNECTIONS']) || !($oConnection = BxDolConnection::getObjectInstance($CNF['OBJECT_CONNECTIONS'])))
+                return '';
+            $iInvitedProfileId = $aData['invited_profile_id'];
+            if ($iInvitedProfileId != bx_get_logged_profile_id())
+                return '';
+            if ($bAccept){
+                if($oConnection && !$oConnection->isConnected($iInvitedProfileId, $iGroupProfileId)){
+                    $oConnection->addConnection($iInvitedProfileId, $iGroupProfileId);
+                    $oConnection->addConnection($iGroupProfileId, $iInvitedProfileId);
+                }
+            }
+            $this->_oDb->deleteInviteByKey($sKey, $iGroupProfileId);
+        }   
+    }
+
+    public function actionCheckName()
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+    	$sName = bx_process_input(bx_get('name'));
+    	if(empty($sName))
+            return echoJson(array());
+
+        $sResult = '';
+
+        $iId = (int)bx_get('id');
+        if(!empty($iId)) {
+            $aPrice = $this->_oDb->getPrices(array('type' => 'by_id', 'value' => $iId)); 
+            if(strcmp($sName, $aPrice[$CNF['FIELD_PRICE_NAME']]) == 0) 
+                $sResult = $sName;
+        }
+
+    	echoJson(array(
+            'name' => !empty($sResult) ? $sResult : $this->_oConfig->getPriceName($sName)
+    	));
+    }
+
+    public function actionGetQuestionnaire()
+    {
+        $sSource = '';
+        if(($sSource = bx_get('s')) !== false)
+            $sSource = bx_process_url_param($sSource);
+
+        $sObject = '';
+        if(($sObject = bx_get('o')) !== false)
+            $sObject = bx_process_url_param($sObject);
+        
+        $sAction = '';
+        if(($sAction = bx_get('a')) !== false)
+            $sAction = bx_process_url_param($sAction);
+
+        $iContentProfileId = 0;
+        if(($iContentProfileId = bx_get('cpi')) !== false)
+            $iContentProfileId = (int)$iContentProfileId;
+
+        echoJson($this->serviceGetQuestionnaire($sSource, $sObject, $sAction, $iContentProfileId));
+    }
+
+    public function serviceManageTools($sType = 'common')
+    {
+        $this->_oTemplate->addJs(['modules/base/groups/js/|manage_tools.js']);
+
+        return parent::serviceManageTools($sType);
+    }
+    
+    public function decodeDataAPI($aData, $aParams = [])
+    {
+        $CNF = $this->_oConfig->CNF;
+        
+        $aResult = parent::decodeDataAPI($aData, $aParams);
+
+        if(isset($aParams['template']) && $aParams['template'] == 'unit_wo_info')
+            return $aResult;
+
+        if(getParam('sys_api_conn_in_prof_units') == 'on' && ($oConnection = BxDolConnection::getObjectInstance($CNF['OBJECT_CONNECTIONS'])) !== false) {
+            $oProfile = BxDolProfile::getInstanceByContentAndType($aData[$CNF['FIELD_ID']], $this->_aModule['name']);
+
+            $aResult['members_count'] = $oConnection->getConnectedInitiatorsCount($oProfile->id(), false);
+            $aResult['members_list'] = $oConnection->getConnectedListAPI($oProfile->id(), false, BX_CONNECTIONS_CONTENT_TYPE_INITIATORS);
+        }
+
+        return array_merge($aResult, [
+            'author_data' => BxDolProfile::getData($aData[$CNF['FIELD_AUTHOR']]),
+            'visibility' => $aData[$CNF['FIELD_ALLOW_VIEW_TO']]
+        ]);
+    }
+
+    public function serviceGetMenuAddonManageTools()
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $aResult = parent::serviceGetMenuAddonManageTools();
+
+        if(!empty($CNF['FIELD_STATUS_ADMIN']))
+            $aResult['counter1_value'] = $this->_oDb->getEntriesNumByParams([[
+                'key' => $CNF['FIELD_STATUS_ADMIN'],
+                'value' => BX_BASE_MOD_GENERAL_STATUS_PENDING, 
+                'operator' => '='
+            ]]);
+
+        return $aResult;
+    }
+
+    public function serviceGetOptionsMembersMode()
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        return array(
+            array('key' => '', 'value' => _t('_None')),
+            array('key' => BX_BASE_MOD_GROUPS_MMODE_MULTI_ROLES, 'value' => _t($CNF['T']['option_members_mode_' . BX_BASE_MOD_GROUPS_MMODE_MULTI_ROLES])),
+        );
+    }
+
+    public function serviceGetSearchResultUnit ($iContentId, $sUnitTemplate = '')
+    {
+        if(empty($sUnitTemplate))
+            $sUnitTemplate = 'unit.html';
+
+        return parent::serviceGetSearchResultUnit($iContentId, $sUnitTemplate);
+    }
+
+    /**
+     * @see BxBaseModProfileModule::serviceGetSpaceTitle
+     */ 
+    public function serviceGetSpaceTitle()
+    {
+        return _t($this->_oConfig->CNF['T']['txt_sample_single']);
+    }
+    
+    /**
+     * @see iBxDolProfileService::serviceGetParticipatingProfiles
+     */ 
+    public function serviceGetParticipatingProfiles($iProfileId, $aConnectionObjects = false)
+    {
+        if (isset($this->_oConfig->CNF['OBJECT_CONNECTIONS'])){
+            $aConnectionObjects = array($this->_oConfig->CNF['OBJECT_CONNECTIONS'], 'sys_profiles_subscriptions');
+            return parent::serviceGetParticipatingProfiles($iProfileId, $aConnectionObjects);
+        }
+        return parent::serviceGetParticipatingProfiles($iProfileId);
+    }
+
+    public function serviceGetSafeServices()
+    {
+        return array_merge(parent::serviceGetSafeServices(), [
+            'GetQuestionnaire' => '',
+            'GetInitialMembers' => '',
+            'EntityInvite' => '',
+        ]);
+    }
+
+    /**
+     * Check if this module entry can be used as profile
+     */
+    public function serviceActAsProfile ()
+    {
+        return false;
+    }
+
+    /**
+     * Check if this module is group profile
+     */
+    public function serviceIsGroupProfile ()
+    {
+        return true;
+    }
+
+    public function serviceIsEnableForContext($iProfileId = 0)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        $bRet = true;
+
+        $sCnfKey = 'ENABLE_FOR_CONTEXT_IN_MODULES';
+        if(empty($iProfileId) || empty($CNF[$sCnfKey]) || !is_array($CNF[$sCnfKey]))
+            $bRet = false;
+
+        if($bRet && !($oProfile = BxDolProfile::getInstance($iProfileId)))
+            $bRet = false;
+
+        if($bRet && !in_array($oProfile->getModule(), $CNF[$sCnfKey]))
+            $bRet = false;
+
+        bx_alert($this->getName(), 'is_enabled_for_context', $iProfileId, false, [
+            'cnf' => $CNF,
+            'module' => $this,
+            'context_profile' => $oProfile,
+            'result' => &$bRet
+        ]);
+
+        return $bRet;
+    }
+
+    /**
+     * check if provided profile is member of the group 
+     */ 
+    public function serviceIsFan ($iGroupProfileId, $iProfileId = false) 
+    {
+        $oGroupProfile = BxDolProfile::getInstance($iGroupProfileId);
+        return $oGroupProfile !== false && $this->isFan($oGroupProfile->getContentId(), $iProfileId);
+    }
+
+    /**
+     * check if provided profile is admin of the group 
+     */ 
+    public function serviceIsAdmin ($iGroupProfileId, $iProfileId = false) 
+    {
+        if(!$iProfileId)
+            $iProfileId = bx_get_logged_profile_id();
+
+        $oGroupProfile = BxDolProfile::getInstance($iGroupProfileId);
+        if(!$oGroupProfile)
+            return false;
+
+        $iGroupContentId = $oGroupProfile->getContentId();
+        if(!$this->isFan($iGroupContentId, $iProfileId))
+            return false;
+
+        $aGroupContentInfo = $this->_oDb->getContentInfoById($iGroupContentId);
+        return $this->_oDb->isAdmin($iGroupProfileId, $iProfileId, $aGroupContentInfo);
+    }
+
+    public function serviceGetAdminRole($iGroupProfileId, $iProfileId = false)
+    {
+        if(!$iProfileId)
+            $iProfileId = bx_get_logged_profile_id();
+
+        if(!$this->serviceIsAdmin($iGroupProfileId, $iProfileId))
+            return 0;
+
+        return $this->_oDb->getRole($iGroupProfileId, $iProfileId);
+    }
+
+    /*
+     * Get context (group) advanced members who can perform the specified action.
+     * If 'Roles' are disabled for the context then all context admins are returned.
+     */
+    public function serviceGetAdminsByAction($iGroupProfileId, $mixedAction)
+    {
+        if(!$this->_oConfig->isAdmins())
+            return [];
+
+        $aGroupContentInfo = $this->_oDb->getContentInfoByProfileId($iGroupProfileId);
+        if(empty($aGroupContentInfo) || !is_array($aGroupContentInfo))
+            return [];
+
+        $aAdmins = $this->_oDb->getAdmins($iGroupProfileId);
+        if(!$this->_oConfig->isRoles())
+            return $aAdmins;
+
+        if(!is_array($mixedAction))
+            $mixedAction = [$mixedAction];
+
+        $aResult = [];
+        foreach($mixedAction as $sAction)
+            foreach($aAdmins as $iAdminProfileId)
+                if(!in_array($iAdminProfileId, $aResult) && $this->isAllowedActionByRole($sAction, $aGroupContentInfo, $iGroupProfileId, $iAdminProfileId))
+                    $aResult[] = $iAdminProfileId;
+
+        return $aResult;
+    }
+
+    public function serviceGetAdminsToManageContent($iGroupProfileId)
+    {
+        return $this->serviceGetAdminsByAction($iGroupProfileId, [
+            BX_BASE_MOD_GROUPS_ACTION_EDIT_CONTENT, 
+            BX_BASE_MOD_GROUPS_ACTION_DELETE_CONTENT
+        ]);
+    }
+
+    /**
+     * Delete profile from fans and admins tables
+     * @param $iProfileId profile id 
+     */
+    public function serviceDeleteProfileFromFansAndAdmins ($iProfileId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $this->_oDb->deleteAdminsByProfileId($iProfileId);
+
+        if (isset($CNF['OBJECT_CONNECTIONS']) && ($oConnection = BxDolConnection::getObjectInstance($CNF['OBJECT_CONNECTIONS'])))
+            $oConnection->onDeleteInitiatorAndContent($iProfileId);
+    }
+
+    /**
+     * Reset group's author for particular group
+     * @param $iContentId group id 
+     * @parem $iAuthorId new author profile ID
+     * @return false of error, or number of updated records on success
+     */
+    public function serviceReassignEntityAuthor ($iContentId, $iAuthorId = 0)
+    {
+        $aContentInfo = $this->_oDb->getContentInfoById((int)$iContentId);
+        if (!$aContentInfo)
+            return false;
+
+        if (empty($iAuthorId)) {
+            $oGroupProfile = BxDolProfile::getInstanceByContentAndType($iContentId, $this->getName());
+            if (!$oGroupProfile)
+                return false;
+
+            $aAdmins = $this->_oDb->getAdmins($oGroupProfile->id());
+            if($aAdmins)
+                $iAuthorId = array_pop($aAdmins);
+        }
+
+        return $this->_oDb->updateAuthorById($iContentId, $iAuthorId);
+    }
+
+    /**
+     * Entry actions and social sharing block
+     */
+    public function serviceEntityAllActions ($mixedContent = false, $aParams = array())
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!empty($mixedContent)) {
+            if(!is_array($mixedContent))
+                $mixedContent = array((int)$mixedContent, (method_exists($this->_oDb, 'getContentInfoById')) ? $this->_oDb->getContentInfoById((int)$mixedContent) : array());
+        }
+        else {
+            $mixedContent = $this->_getContent();
+            if($mixedContent === false)
+                return false;
+        }
+
+        list($iContentId, $aContentInfo) = $mixedContent;
+
+        if(!empty($CNF['FIELD_PICTURE']) && !empty($aContentInfo[$CNF['FIELD_PICTURE']]))
+            $aParams = array_merge(array(
+                'entry_thumb' => (int)$aContentInfo[$CNF['FIELD_PICTURE']]
+            ), $aParams); 
+
+        return parent::serviceEntityAllActions ($mixedContent, $aParams);
+    }
+    
+    /**
+     * Reset group's author when author profile is deleted
+     * @param $iProfileId author profile id 
+     * @param $iAuthorId new author profile id 
+     * @return number of changed items
+     */
+    public function serviceReassignEntitiesByAuthor ($iProfileId, $iAuthorId = 0)
+    {
+        $a = $this->_oDb->getEntriesByAuthor((int)$iProfileId);
+        if (!$a)
+            return 0;
+
+        $iCount = 0;
+        foreach ($a as $aContentInfo)
+            $iCount += ('' == $this->serviceReassignEntityAuthor($aContentInfo[$this->_oConfig->CNF['FIELD_ID']], $iAuthorId) ? 1 : 0);
+
+        return $iCount;
+    }
+
+    public function servicePrepareFields ($aFieldsProfile)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        
+        $aFieldsProfile[$CNF['FIELD_NAME']] = $aFieldsProfile['name'];
+        $aFieldsProfile[$CNF['FIELD_TEXT']] = isset($aFieldsProfile['description']) ? $aFieldsProfile['description'] : '';
+        unset($aFieldsProfile['name']);
+        unset($aFieldsProfile['description']);
+        return $aFieldsProfile;
+    }
+
+    public function serviceOnRemoveConnection ($iGroupProfileId, $iInitiatorId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        list ($iProfileId, $iGroupProfileId, $oGroupProfile) = $this->_prepareProfileAndGroupProfile($iGroupProfileId, $iInitiatorId);
+        if (!$oGroupProfile)
+            return false;
+
+        $this->_oDb->fromAdmins($iGroupProfileId, $iProfileId);
+
+        if ($oConn = BxDolConnection::getObjectInstance('sys_profiles_subscriptions'))
+            return $oConn->removeConnection($iProfileId, $iGroupProfileId);
+
+        return false;
+    }
+
+    public function serviceAddMutualConnection ($iGroupProfileId, $iInitiatorId, $bSendInviteOnly = false)
+    {        
+        $CNF = &$this->_oConfig->CNF;
+
+        list ($iProfileId, $iGroupProfileId, $oGroupProfile) = $this->_prepareProfileAndGroupProfile($iGroupProfileId, $iInitiatorId);
+        if (!$oGroupProfile)
+            return false;
+
+        if (!($aContentInfo = $this->_oDb->getContentInfoById((int)BxDolProfile::getInstance($iGroupProfileId)->getContentId())))
+            return false;
+
+        if (!isset($CNF['OBJECT_CONNECTIONS']) || !($oConnection = BxDolConnection::getObjectInstance($CNF['OBJECT_CONNECTIONS'])))
+            return false;
+
+        $sEntryTitle = $aContentInfo[$CNF['FIELD_NAME']];
+        $sEntryUrl = bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $CNF['URI_VIEW_ENTRY'] . '&id=' . $aContentInfo[$CNF['FIELD_ID']]));
+
+        // send invitation to the group 
+        $sModule = $this->getName();
+        if ($bSendInviteOnly && !$oConnection->isConnected((int)$iInitiatorId, $oGroupProfile->id()) && !$oConnection->isConnected($oGroupProfile->id(), (int)$iInitiatorId) && bx_get_logged_profile_id() != $iProfileId) {
+            /**
+             * @hooks
+             * @hookdef hook-bx_base_groups-join_invitation '{module_name}', 'join_invitation' - hook before adding (sending) new join to context invitation
+             * - $unit_name - module name
+             * - $action - equals `join_invitation`
+             * - $object_id - context id
+             * - $sender_id - context profile id
+             * - $extra_params - array of additional params with the following array keys:
+             *      - `content` - [array] context info array as key&value pairs
+             *      - `entry_title` - [string] context title
+             *      - `entry_url` - [string] context URL
+             *      - `group_profile` - [int] context profile id
+             *      - `profile` - [int] profile id who was invited
+             *      - `notification_subobject_id` - [int] profile id who was invited
+             *      - `object_author_id` - [int] context profile id
+             * @hook @ref hook-bx_base_groups-join_invitation
+             */
+            bx_alert($sModule, 'join_invitation', $aContentInfo[$CNF['FIELD_ID']], $iGroupProfileId, [
+                'content' => $aContentInfo, 
+                'entry_title' => $sEntryTitle, 
+                'entry_url' => $sEntryUrl, 
+                'group_profile' => $iGroupProfileId, 
+                'profile' => $iProfileId, 
+                'notification_subobject_id' => $iProfileId, 
+                'object_author_id' => $iGroupProfileId
+            ]);
+
+            /**
+             * 'Invitation Received' alert for Notifications module.
+             * Note. It's essential to use Recipient ($iInitiatorId) in 'object_author_id' parameter. 
+             * In this case notification will be received by Recipient profile.
+             */
+            /**
+             * @hooks
+             * @hookdef hook-bx_base_groups-join_invitation_notif '{module_name}', 'join_invitation_notif' - hook before adding new join to context invitation. Is needed for Notifications module.
+             * - $unit_name - module name
+             * - $action - equals `join_invitation_notif`
+             * - $object_id - context id
+             * - $sender_id - context profile id
+             * - $extra_params - array of additional params with the following array keys:
+             *      - `object_author_id` - [int] profile id who was invited
+             *      - `privacy_view` - [int] or [string] privacy for view context action, @see BxDolPrivacy
+             * @hook @ref hook-bx_base_groups-join_invitation_notif
+             */
+            bx_alert($sModule, 'join_invitation_notif', $aContentInfo[$CNF['FIELD_ID']], $iGroupProfileId, [
+                'object_author_id' => $iInitiatorId, 
+                'privacy_view' => isset($aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']]) ? $aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']] : 3, 
+            ]);
+        }
+        // send notification to group's admins that new connection is pending confirmation 
+        elseif (!$bSendInviteOnly && $oConnection->isConnected((int)$iInitiatorId, $oGroupProfile->id()) && !$oConnection->isConnected($oGroupProfile->id(), (int)$iInitiatorId) && $aContentInfo['join_confirmation']) {
+            /**
+             * @hooks
+             * @hookdef hook-bx_base_groups-join_request '{module_name}', 'join_request' - hook before adding new join to context request
+             * - $unit_name - module name
+             * - $action - equals `join_request`
+             * - $object_id - context id
+             * - $sender_id - context profile id
+             * - $extra_params - array of additional params with the following array keys:
+             *      - `object_author_id` - [int] context profile id
+             *      - `performer_id` - [int] profile id who wants to join
+             *      - `content` - [array] context info array as key&value pairs
+             *      - `entry_title` - [string] context title
+             *      - `entry_url` - [string] context URL
+             *      - `group_profile` - [int] context profile id
+             *      - `profile` - [int] profile id who wants to join
+             * @hook @ref hook-bx_base_groups-join_request
+             */
+            bx_alert($this->getName(), 'join_request', $aContentInfo[$CNF['FIELD_ID']], $iGroupProfileId, [
+            	'object_author_id' => $iGroupProfileId,
+            	'performer_id' => $iProfileId, 
+
+            	'content' => $aContentInfo, 
+            	'entry_title' => $sEntryTitle, 
+            	'entry_url' => $sEntryUrl, 
+
+            	'group_profile' => $iGroupProfileId, 
+            	'profile' => $iProfileId
+            ]);
+        }
+        // send notification that join request was accepted 
+        else if (!$bSendInviteOnly && $oConnection->isConnected((int)$iInitiatorId, $oGroupProfile->id(), true) && $oGroupProfile->getModule() != $this->getName() && bx_get_logged_profile_id() != $iProfileId) {
+            /**
+             * @hooks
+             * @hookdef hook-bx_base_groups-join_request_accepted '{module_name}', 'join_request_accepted' - hook before accepting join to context request
+             * It's equivalent to @ref hook-bx_base_groups-join_request
+             * @hook @ref hook-bx_base_groups-join_request_accepted
+             */
+            bx_alert($this->getName(), 'join_request_accepted', $aContentInfo[$CNF['FIELD_ID']], $iGroupProfileId, [
+            	'object_author_id' => $iGroupProfileId,
+            	'performer_id' => $iProfileId,
+
+            	'content' => $aContentInfo, 
+            	'entry_title' => $sEntryTitle, 
+            	'entry_url' => $sEntryUrl, 
+
+            	'group_profile' => $iGroupProfileId, 
+            	'profile' => $iProfileId
+            ]);
+        }
+
+        // new fan was added
+        if ($oConnection->isConnected($oGroupProfile->id(), (int)$iInitiatorId, true)) {
+            // follow group on join
+            if (BxDolService::call($oGroupProfile->getModule(), 'act_as_profile')){
+                 $this->addFollower($oGroupProfile->id(), (int)$iInitiatorId);
+            }
+            else{
+                 $this->addFollower((int)$iInitiatorId, $oGroupProfile->id()); 
+            }
+
+            /**
+             * @hooks
+             * @hookdef hook-bx_base_groups-fan_added '{module_name}', 'fan_added' - hook before adding (registering) new context member
+             * It's equivalent to @ref hook-bx_base_groups-join_request
+             * @hook @ref hook-bx_base_groups-fan_added
+             */
+            bx_alert($this->getName(), 'fan_added', $aContentInfo[$CNF['FIELD_ID']], $iGroupProfileId, [
+            	'object_author_id' => $iGroupProfileId,
+            	'performer_id' => $iProfileId,
+
+            	'content' => $aContentInfo,
+            	'entry_title' => $sEntryTitle, 
+            	'entry_url' => $sEntryUrl,
+
+            	'group_profile' => $iGroupProfileId, 
+            	'profile' => $iProfileId,
+            ]);
+
+            $this->doAudit($iGroupProfileId, $iInitiatorId, '_sys_audit_action_group_join_request_accepted');
+            
+            return false;
+        }
+
+        // don't automatically add connection (mutual) if group requires manual join confirmation
+        if ($bSendInviteOnly || $aContentInfo['join_confirmation'])
+            return false;
+
+        // check if connection already exists
+        if ($oConnection->isConnected($oGroupProfile->id(), (int)$iInitiatorId, true) || $oConnection->isConnected($oGroupProfile->id(), (int)$iInitiatorId))
+            return false;
+
+        if (!$oConnection->addConnection($oGroupProfile->id(), (int)$iInitiatorId))
+            return false;
+
+        return true;
+    }
+
+    public function serviceFansTable ()
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $oGrid = BxDolGrid::getObjectInstance($CNF['OBJECT_GRID_CONNECTIONS']);
+        if(!$oGrid)
+            return false;
+
+        if($this->_bIsApi){
+            return [
+                bx_api_get_block('grid', $oGrid->getCodeAPI())
+            ];
+        }
+
+        return $oGrid->getCode();
+    }
+    
+    public function serviceInvitesTable ()
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!isset($CNF['OBJECT_GRID_INVITES']))
+            return false;
+
+        $oGrid = BxDolGrid::getObjectInstance($CNF['OBJECT_GRID_INVITES']);
+        if(!$oGrid)
+            return false;
+
+        if($this->_bIsApi)
+            return [
+                bx_api_get_block('grid', $oGrid->getCodeAPI())
+            ];
+
+        return $oGrid->getCode();
+    }
+
+    public function serviceBansTable ()
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!isset($CNF['OBJECT_GRID_BANS']))
+            return false;
+
+        $oGrid = BxDolGrid::getObjectInstance($CNF['OBJECT_GRID_BANS']);
+        if(!$oGrid)
+            return false;
+
+        if($this->_bIsApi)
+            return [
+                bx_api_get_block('grid', $oGrid->getCodeAPI())
+            ];
+
+        return $oGrid->getCode();
+    }
+
+    public function serviceFans ($iContentId = 0, $bAsArray = false)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if (!$iContentId)
+            $iContentId = bx_process_input(bx_get('id'), BX_DATA_INT);
+
+        if (!$iContentId)
+            return false;
+
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if (!$aContentInfo)
+            return false;
+
+        if (!($oGroupProfile = BxDolProfile::getInstanceByContentAndType($iContentId, $this->getName())))
+            return false;
+
+        if(!$bAsArray) {
+            bx_import('BxDolConnection');
+            $mixedResult = $this->serviceBrowseConnectionsQuick ($oGroupProfile->id(), $CNF['OBJECT_CONNECTIONS'], BX_CONNECTIONS_CONTENT_TYPE_CONTENT, true);
+            if (!$mixedResult)
+                return MsgBox(_t('_sys_txt_empty'));
+        }
+        else
+            $mixedResult = BxDolConnection::getObjectInstance($CNF['OBJECT_CONNECTIONS'])->getConnectedContent($oGroupProfile->id(), true);
+
+        return $mixedResult;
+    }
+    
+    public function serviceFansWithoutAdmins ($iContentId = 0, $bAsArray = false)
+    {
+        if (!$iContentId)
+            $iContentId = bx_process_input(bx_get('id'), BX_DATA_INT);
+        if (!$iContentId)
+            return false;
+
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if (!$aContentInfo)
+            return false;
+
+        if (!($oGroupProfile = BxDolProfile::getInstanceByContentAndType($iContentId, $this->getName())))
+            return false;
+        
+        $CNF = &$this->_oConfig->CNF;
+
+        $aFans = BxDolConnection::getObjectInstance($CNF['OBJECT_CONNECTIONS'])->getConnectedContent($oGroupProfile->id(), true);
+        if(empty($aFans) || !is_array($aFans))
+            return false;
+
+        $aAdmins = $this->_oDb->getAdmins($oGroupProfile->id());
+        if(!empty($aAdmins) && is_array($aAdmins))
+            $aFans = array_diff($aFans, $aAdmins);
+
+        $iStart = (int)bx_get('start');
+        $iLimit = !empty($CNF['PARAM_NUM_CONNECTIONS_QUICK']) ? getParam($CNF['PARAM_NUM_CONNECTIONS_QUICK']) : 4;
+        if(!$iLimit)
+            $iLimit = 4;
+
+        return $this->_serviceBrowseQuick($aFans, $iStart, $iLimit);
+    }
+
+    public function serviceAdmins ($iContentId = 0)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!$iContentId)
+            $iContentId = bx_process_input(bx_get('id'), BX_DATA_INT);
+        if(!$iContentId)
+            return false;
+
+        $oGroupProfile = BxDolProfile::getInstanceByContentAndType($iContentId, $this->getName());
+        if(!$oGroupProfile)
+            return false;
+
+        $iStart = (int)bx_get('start');
+        $iLimit = !empty($CNF['PARAM_NUM_CONNECTIONS_QUICK']) ? getParam($CNF['PARAM_NUM_CONNECTIONS_QUICK']) : 4;
+        if(!$iLimit)
+            $iLimit = 4;
+        
+        $aProfiles = $this->_oDb->getAdmins($oGroupProfile->id(), $iStart,  $iLimit+1);
+        if(empty($aProfiles) || !is_array($aProfiles))
+            return false;
+
+        return $this->_serviceBrowseQuick($aProfiles, $iStart, $iLimit);
+    }
+
+    public function serviceMembersByRole ($iContentId = 0, $iRole = BX_BASE_MOD_GROUPS_ROLE_COMMON)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!$iContentId)
+            $iContentId = bx_process_input(bx_get('id'), BX_DATA_INT);
+        if(!$iContentId)
+            return false;
+
+        $oGroupProfile = BxDolProfile::getInstanceByContentAndType($iContentId, $this->getName());
+        if(!$oGroupProfile)
+            return false;
+
+        $iStart = (int)bx_get('start');
+        $iLimit = !empty($CNF['PARAM_NUM_CONNECTIONS_QUICK']) ? getParam($CNF['PARAM_NUM_CONNECTIONS_QUICK']) : 4;
+        if(!$iLimit)
+            $iLimit = 4;
+
+        $aProfiles = $this->_oDb->getRoles([
+            'type' => 'fan_pids_by_group_pid', 
+            'group_profile_id' => $oGroupProfile->id(), 
+            'role' => $iRole,
+            'start' => $iStart,  
+            'limit' => $iLimit + 1
+        ]);
+
+        if(empty($aProfiles) || !is_array($aProfiles))
+            return false;
+
+        return $this->_serviceBrowseQuick($aProfiles, $iStart, $iLimit);
+    }
+
+    public function serviceBrowseJoinedEntries ($iProfileId = 0, $bDisplayEmptyMsg = false)
+    {
+        if (!$iProfileId)
+            $iProfileId = bx_process_input(bx_get('profile_id'), BX_DATA_INT);
+        if (!$iProfileId)
+            return '';
+
+        return $this->_serviceBrowse ('joined_entries', array('joined_profile' => $iProfileId), BX_DB_PADDING_DEF, $bDisplayEmptyMsg);
+    }
+    
+    public function serviceBrowseFollowedEntries ($iProfileId = 0, $bDisplayEmptyMsg = false)
+    {
+        if (!$iProfileId)
+            $iProfileId = bx_process_input(bx_get('profile_id'), BX_DATA_INT);
+        if (!$iProfileId)
+            return '';
+
+        return $this->_serviceBrowse ('followed_entries', array('followed_profile' => $iProfileId), BX_DB_PADDING_DEF, $bDisplayEmptyMsg);
+    }
+
+    public function serviceBrowseCreatedEntries ($iProfileId = 0, $bDisplayEmptyMsg = false)
+    {
+        if (!$iProfileId)
+            $iProfileId = bx_process_input(bx_get('profile_id'), BX_DATA_INT);
+        if (!$iProfileId)
+            return '';
+
+        return $this->_serviceBrowse ('created_entries', array('author' => $iProfileId), BX_DB_PADDING_DEF, $bDisplayEmptyMsg);
+    }
+
+    public function serviceBrowseRecommendationsFans ($iProfileId = 0, $aParams = [])
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if($this->_bIsApi)
+            $aParams = bx_api_get_browse_params($aParams, true);
+
+        if(!$iProfileId)
+            $iProfileId = bx_get_logged_profile_id();
+        if(!$iProfileId)
+            return '';
+
+        $aParams = array_merge([
+            'empty_message' => false,
+            'start' => 0,
+            'per_page' => 0
+        ], $aParams);
+
+        if(($iStartGet = bx_get('start')) !== false)
+            $aParams['start'] = (int)$iStartGet;
+
+        if(($iPerPageGet = bx_get('per_page')) !== false)
+            $aParams['per_page'] = (int)$iPerPageGet;
+
+        $oRecommendation = BxDolRecommendation::getObjectInstance($CNF['OBJECT_RECOMMENDATIONS_FANS']);
+        if(!$oRecommendation)
+            return false;
+
+        if($this->_bIsApi) {
+            $aData = $oRecommendation->getCodeAPI($iProfileId, $aParams);
+            $aData = array_merge($aData, [
+                'module' => 'system',
+                'unit' => 'mixed', 
+                'request_url' => '/api.php?r=bx_groups/browse_recommendations_fans&params[]=' . $iProfileId . '&params[]='
+            ]);
+
+            return [bx_api_get_block('browse', $aData)];
+        }
+
+        $sCode = $oRecommendation->getCode($iProfileId, $aParams);
+        if(!$sCode && $aParams['empty_message'])
+            $sCode = MsgBox(_t(!empty($aParams['empty_message_text']) ? $aParams['empty_message_text'] : '_Empty'));
+
+        return $sCode;
+    }
+
+    public function serviceBrowseMembers ($iProfileId = 0, $aParams = [])
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!$iProfileId)
+            $iProfileId = bx_process_input(bx_get('profile_id'), BX_DATA_INT);
+        if(!$iProfileId)
+            return '';
+
+        $oGroupProfile = BxDolProfile::getInstance($iProfileId);
+        if(!($oGroupProfile))
+            return '';
+
+        return bx_srv('system', 'browse_members', [$oGroupProfile->id(), $CNF['OBJECT_CONNECTIONS'], $aParams], 'TemplServiceProfiles');
+    }
+
+    public function serviceEntityEditQuestionnaire($iProfileId = 0)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!$iProfileId)
+            $iProfileId = bx_process_input(bx_get('profile_id'), BX_DATA_INT);
+        if(!$iProfileId)
+            return '';
+
+        $aProfileInfo = BxDolProfileQuery::getInstance()->getInfoById($iProfileId);
+        if(empty($aProfileInfo) || !is_array($aProfileInfo))
+            return '';
+        
+        $aContentInfo = $this->_oDb->getContentInfoById($aProfileInfo['content_id']);
+        if(empty($aContentInfo) || !is_array($aContentInfo))
+            return '';
+
+        if($this->checkAllowedEdit($aContentInfo) !== CHECK_ACTION_RESULT_ALLOWED)
+            return MsgBox(_t('_Access denied'));
+
+        $oGrid = BxDolGrid::getObjectInstance($CNF['OBJECT_GRID_QUESTIONS_MANAGE']);
+        if(!$oGrid)
+            return '';
+        
+        if($this->_bIsApi){
+            return [
+                bx_api_get_block('grid', $oGrid->getCodeAPI())
+            ];
+            
+        }
+
+        return $oGrid->getCode();
+    }
+
+    public function serviceEntityPricing($iProfileId = 0)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!$iProfileId)
+            $iProfileId = bx_process_input(bx_get('profile_id'), BX_DATA_INT);
+        if(!$iProfileId)
+            return '';
+
+        if(!$this->_oConfig->isPaidJoin())
+            return '';
+
+        $oPayments = BxDolPayments::getInstance();
+    	if(!$oPayments->isActive())
+            return MsgBox(_t('_sys_payments_err_no_payments'));
+
+        if($this->checkAllowedUsePaidJoin() !== CHECK_ACTION_RESULT_ALLOWED)
+            return MsgBox(_t('_Access denied'));
+
+        if($this->checkAllowedManageAdmins($iProfileId) !== CHECK_ACTION_RESULT_ALLOWED)
+            return MsgBox(_t('_Access denied'));
+
+        $oGrid = BxDolGrid::getObjectInstance($CNF['OBJECT_GRID_PRICES_MANAGE']);
+        if(!$oGrid)
+            return '';
+
+        $sNote = '';
+        if(!$oPayments->isAcceptingPayments($this->_iProfileId))
+            $sNote = MsgBox(_t('_sys_payments_err_not_accept_payments', $oPayments->getDetailsUrl()));
+
+        return $sNote . $oGrid->getCode();
+    }
+
+    public function serviceEntityJoin($iProfileId = 0)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!$iProfileId)
+            $iProfileId = bx_process_input(bx_get('profile_id'), BX_DATA_INT);
+        if(!$iProfileId)
+            return '';
+
+        if(!$this->_oConfig->isPaidJoin())
+            return '';
+
+        $oGrid = BxDolGrid::getObjectInstance($CNF['OBJECT_GRID_PRICES_VIEW']);
+        if(!$oGrid)
+            return '';
+
+        return $oGrid->getCode();
+    }
+
+    public function serviceEntityInvite ($iContentId = 0, $bErrorMsg = true)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!isset($CNF['OBJECT_FORM_ENTRY_DISPLAY_INVITE']))
+            return false;
+
+        return $this->_serviceEntityForm ('inviteForm', $iContentId, false, false, $bErrorMsg);
+    }
+    
+    /**
+     * Entry social sharing block
+     */
+    public function serviceEntitySocialSharing ($mixedContent = false, $aParams = array())
+    {
+        if(!empty($mixedContent)) {
+            if(!is_array($mixedContent))
+               $mixedContent = array((int)$mixedContent, array());
+        }
+        else {
+            $mixedContent = $this->_getContent();
+            if($mixedContent === false)
+                return false;
+        }
+
+        list($iContentId, $aContentInfo) = $mixedContent;
+
+        $oGroupProfile = BxDolProfile::getInstanceByContentAndType((int)$iContentId, $this->getName());
+        if(!$oGroupProfile)
+            return false;
+
+        return parent::serviceEntitySocialSharing(array($iContentId, $aContentInfo), array(
+            'title' => $oGroupProfile->getDisplayName()
+        ));
+    }
+
+    public function serviceIsPricingAvaliable($iProfileId)
+    {
+        if(!$this->_oConfig->isPaidJoin())
+            return false;
+
+        if($this->checkAllowedUsePaidJoin() !== CHECK_ACTION_RESULT_ALLOWED)
+            return false;        
+
+        if($this->checkAllowedManageAdmins($iProfileId) !== CHECK_ACTION_RESULT_ALLOWED)
+            return false;
+
+        return true;
+    }
+
+    public function serviceIsPaidJoinAvaliable($iGroupProfileId, $iProfileId = 0)
+    {
+        return $this->isPaidJoinByProfileForProfile($iGroupProfileId, $iProfileId);
+    }
+
+    public function serviceIsPaidJoinAvaliableByContent($iGroupContentId, $iProfileId = 0)
+    {
+        $oGroupProfile = BxDolProfile::getInstanceByContentAndType($iGroupContentId, $this->getName());
+        if(!$oGroupProfile)
+            return false;
+
+        return $this->isPaidJoinByProfileForProfile($oGroupProfile->id(), $iProfileId);
+    }
+
+    public function serviceIsFreeJoinAvaliable($iGroupProfileId, $iProfileId = 0)
+    {
+        return !$this->isPaidJoinByProfileForProfile($iGroupProfileId, $iProfileId);
+    }
+
+    public function serviceIsFreeJoinAvaliableByContent($iGroupContentId, $iProfileId = 0)
+    {
+        $oGroupProfile = BxDolProfile::getInstanceByContentAndType($iGroupContentId, $this->getName());
+        if(!$oGroupProfile)
+            return false;
+
+        return !$this->isPaidJoinByProfileForProfile($oGroupProfile->id(), $iProfileId);
+    }
+
+    /**
+     * Is Paid Join enabled in the group and whether a profile can use it.
+     * 
+     * @param type $iGroupProfileId - Group profile ID.
+     * @param type $iProfileId - Profile ID of the user who wants to join.
+     * @return boolean
+     */
+    public function isPaidJoinByProfileForProfile($iGroupProfileId, $iProfileId = 0)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(empty($iProfileId))
+            $iProfileId = $this->_iProfileId;
+
+        if(BxDolConnection::getObjectInstance($CNF['OBJECT_CONNECTIONS'])->isConnected($iProfileId, $iGroupProfileId))
+            return false;
+
+        return $this->isPaidJoinByProfile($iGroupProfileId);
+    }
+
+    /**
+     * Is Paid Join enabled as is and whether a group has pricing plans added.
+     * 
+     * @param type $iGroupProfileId - Group profile ID.
+     * @return boolean
+     */
+    public function isPaidJoinByProfile($iGroupProfileId)
+    {
+        if(!$this->_oConfig->isPaidJoin())
+            return false;
+
+        $aPrices = $this->_oDb->getPrices(array('type' => 'by_profile_id', 'profile_id' => $iGroupProfileId));
+        if(empty($aPrices) || !is_array($aPrices))
+            return false;
+
+        return true;
+    }
+
+    /**
+     * Integration with Payments.
+     */
+    public function serviceGetPaymentData()
+    {
+        return $this->_aModule;
+    }
+
+    public function serviceGetCartItem($mixedItemId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!$mixedItemId)
+            return array();
+
+        if(is_numeric($mixedItemId))
+            $aItem = $this->_oDb->getPrices(array('type' => 'by_id', 'value' => (int)$mixedItemId));
+        else 
+            $aItem = $this->_oDb->getPrices(array('type' => 'by_name', 'value' => $mixedItemId));
+
+        if(empty($aItem) || !is_array($aItem))
+            return array();
+
+        if(!$this->isPaidJoinByProfile($aItem['profile_id']))
+            return array();
+
+        $oGroupProfile = BxDolProfile::getInstance($aItem['profile_id']);
+        if(!$oGroupProfile)
+            return array();
+
+        $aGroupProfile = $this->_oDb->getContentInfoById($oGroupProfile->getContentId());
+        
+        $aRoles = BxDolFormQuery::getDataItems($CNF['OBJECT_PRE_LIST_ROLES']);
+
+        $sTitle = '';
+        if(!empty($aItem['period']) && !empty($aItem['period_unit']))
+            $sTitle = _t($CNF['T']['txt_cart_item_title'], $oGroupProfile->getDisplayName(), $aRoles[$aItem['role_id']], $aItem['period'], $aItem['period_unit']);
+        else
+            $sTitle = _t($CNF['T']['txt_cart_item_title_lifetime'], $oGroupProfile->getDisplayName(), $aRoles[$aItem['role_id']]);
+
+        return array (
+            'id' => $aItem['id'],
+            'author_id' => $aGroupProfile[$CNF['FIELD_AUTHOR']],
+            'name' => $aItem['name'],
+            'title' => $sTitle,
+            'description' => '',
+            'url' => $oGroupProfile->getUrl(),
+            'price_single' => $aItem['price'],
+            'price_recurring' => $aItem['price'],
+            'period_recurring' => $aItem['period'],
+            'period_unit_recurring' => $aItem['period_unit'],
+            'trial_recurring' => 0
+        );
+    }
+
+    public function serviceGetCartItems($iSellerId)
+    {
+    	$CNF = &$this->_oConfig->CNF;
+
+    	if(empty($iSellerId))
+    	    return array();
+
+        $sModule = $this->getName();
+        $aRoles = BxDolFormQuery::getDataItems($CNF['OBJECT_PRE_LIST_ROLES']);
+
+        $aGroups = $this->_oDb->getEntriesBy(array('type' => 'author', 'author' => $iSellerId));
+
+        $aResult = array();
+        foreach($aGroups as $aGroup) {
+            $oGroupProfile = BxDolProfile::getInstanceByContentAndType($aGroup[$CNF['FIELD_ID']], $sModule);
+            if(!$oGroupProfile)
+                continue;
+
+            $aPrices = $this->_oDb->getPrices(array('type' => 'by_profile_id', 'profile_id' => $oGroupProfile->id()));
+            if(empty($aPrices) || !is_array($aPrices))
+                continue;
+
+            $sTitle = $oGroupProfile->getDisplayName();
+            $sUrl = $oGroupProfile->getUrl();
+
+            foreach($aPrices as $aPrice)
+                $aResult[] = array(
+                    'id' => $aPrice['id'],
+                    'author_id' => $iSellerId,
+                    'name' => $aPrice['name'],
+                    'title' => _t($CNF['T']['txt_cart_item_title'], $sTitle, $aRoles[$aPrice['role_id']], $aPrice['period'], $aPrice['period_unit']),
+                    'description' => '',
+                    'url' => $sUrl,
+                    'price_single' => $aPrice['price'],
+                    'price_recurring' => $aPrice['price'],
+                    'period_recurring' => $aPrice['period'],
+                    'period_unit_recurring' => $aPrice['period_unit']
+               );
+        }
+
+        return $aResult;
+    }
+
+    public function serviceRegisterCartItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder, $sLicense)
+    {
+        return $this->_serviceRegisterItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder);
+    }
+
+    public function serviceRegisterSubscriptionItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder, $sLicense)
+    {
+        return $this->_serviceRegisterItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder);
+    }
+
+    public function serviceReregisterCartItem($iClientId, $iSellerId, $iItemIdOld, $iItemIdNew, $sOrder)
+    {
+        return $this->_serviceReregisterItem($iClientId, $iSellerId, $iItemIdOld, $iItemIdNew, $sOrder);
+    }
+
+    public function serviceReregisterSubscriptionItem($iClientId, $iSellerId, $iItemIdOld, $iItemIdNew, $sOrder)
+    {
+        return $this->_serviceReregisterItem($iClientId, $iSellerId, $iItemIdOld, $iItemIdNew, $sOrder);
+    }
+
+    public function serviceUnregisterCartItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder, $sLicense)
+    {
+        return $this->_serviceUnregisterItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder);
+    }
+
+    public function serviceUnregisterSubscriptionItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder, $sLicense)
+    {
+    	return $this->_serviceUnregisterItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder); 
+    }
+
+    public function serviceCancelSubscriptionItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder)
+    {
+    	return true;
+    }
+
+    protected function _serviceRegisterItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+    	$aItem = $this->serviceGetCartItem($iItemId);
+        if(empty($aItem) || !is_array($aItem))
+            return array();
+
+        $aItemInfo = $this->_oDb->getPrices(array('type' => 'by_id', 'value' => $iItemId));
+
+        $mixedPeriod = false;
+        if((int)$aItemInfo['period'] != 0)
+            $mixedPeriod = array(
+                'period' => (int)$aItemInfo['period'], 
+                'period_unit' => $aItemInfo['period_unit'], 
+                'period_reserve' => $CNF['PARAM_RECURRING_RESERVE']
+            );
+
+        if(!$this->setRole($aItemInfo['profile_id'], $iClientId, $aItemInfo['role_id'], $mixedPeriod, $sOrder))
+            return array();
+
+        return $aItem;
+    }
+
+    protected function _serviceReregisterItem($iClientId, $iSellerId, $iItemIdOld, $iItemIdNew, $sOrder)
+    {
+        $aItem = $this->serviceGetCartItem($iItemIdNew);
+        if(empty($aItem) || !is_array($aItem))
+            return array();
+
+        $aItemInfoOld = $this->_oDb->getPrices(array('type' => 'by_id', 'value' => $iItemIdOld));
+        if(empty($aItemInfoOld) || !is_array($aItemInfoOld))
+            return array();
+
+        $aItemInfoNew = $this->_oDb->getPrices(array('type' => 'by_id', 'value' => $iItemIdNew));
+        if(empty($aItemInfoNew) || !is_array($aItemInfoNew))
+            return array();
+
+        if(!$this->unsetRole($aItemInfoOld['profile_id'], $iClientId))
+            return array();
+        
+        $aResult = $this->_serviceRegisterItem($iClientId, $iSellerId, $iItemIdNew, 1, $sOrder);
+        if(empty($aResult) || !is_array($aResult))
+            return array();
+
+    	return $aItem;
+    }
+
+    protected function _serviceUnregisterItem($iClientId, $iSellerId, $iItemId, $iItemCount, $sOrder)
+    {
+        $aItemInfo = $this->_oDb->getPrices(array('type' => 'by_id', 'value' => $iItemId));
+        if(empty($aItemInfo) || !is_array($aItemInfo))
+            return false;
+
+        return $this->unsetRole($aItemInfo['profile_id'], $iClientId);
+    }
+
+    public function serviceGetQuestionnaire($sSource, $sObject, $sAction, $iContentProfileId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        $sMsg = _t('_sys_txt_not_found');
+
+        $aContentInfo = $this->_oDb->getContentInfoByProfileId($iContentProfileId);
+        if(empty($aContentInfo) || !is_array($aContentInfo))
+            return $this->_bIsApi ? [bx_api_get_msg($sMsg)] : ['code' => 1, 'msg' => $sMsg];
+        
+        $oForm = null;
+        if(($oConnection = BxDolConnection::getObjectInstance($sObject)) !== false) {
+            $oForm = $oConnection->getQuestionnaireForm($sAction, $iContentProfileId, ['request' => [
+                's' => $sSource,
+            ]]);
+
+            if($oForm === false)
+                return $this->_bIsApi ? [bx_api_get_msg($sMsg)] : ['code' => 3, 'msg' => $sMsg];
+        }
+        else
+            return $this->_bIsApi ? [bx_api_get_msg($sMsg)] : ['code' => 2, 'msg' => $sMsg];
+
+        $oForm->initChecker();
+        if($oForm->isSubmittedAndValid()) {
+            $iProfileId = bx_get_logged_profile_id();
+
+            $aQuestions = $this->_oDb->getQuestions(['sample' => 'content_pid', 'content_pid' => $iContentProfileId]);
+            foreach($aQuestions as $aQuestion)
+                $this->_oDb->insertAnswer((int)$aQuestion['id'], $iProfileId, $oForm->getCleanValue('question_' . $aQuestion['id']));
+
+            return $this->_bIsApi ? [] : ['code' => 0, 'o' => $sObject, 'a' => $sAction, 'cpi' => $iContentProfileId, 'ci' => $aContentInfo[$CNF['FIELD_ID']], 'eval' => $this->_oConfig->getJsObject($sSource) . '.connActionPerformed(oData)'];
+        }
+
+        if($this->_bIsApi)
+            return [bx_api_get_block('form', $oForm->getCodeAPI(), ['ext' => [
+                'name' => $this->_oModule->getName(), 
+                'request' => ['url' => $oForm->aFormAttrs['action'], 'immutable' => true]]
+            ])];
+
+        bx_import('BxTemplFunctions');
+        $sContent = BxTemplFunctions::getInstance()->popupBox($this->_oConfig->getHtmlIds('popup_questionnaire'), _t($CNF['T']['popup_title_questionnaire']), $this->_oTemplate->parseHtmlByName('popup_qnr_questionnaire.html', [
+            'form_id' => $oForm->getId(),
+            'form' => $oForm->getCode(true),
+        ]));
+
+        return ['popup' => ['html' => $sContent, 'options' => ['closeOnOuterClick' => false]]];
+    }
+
+    /**
+     * Data for Notifications module
+     */
+    public function serviceGetNotificationsData()
+    {
+    	$sModule = $this->_aModule['name'];
+
+        $aSettingsTypes = ['follow_member', 'follow_context'];
+        if($this->serviceActAsProfile())
+            $aSettingsTypes = ['personal', 'follow_member'];
+
+        return [
+            'handlers' => [
+                ['group' => $sModule . '_vote', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'doVote', 'module_name' => $sModule, 'module_method' => 'get_notifications_vote', 'module_class' => 'Module'],
+                ['group' => $sModule . '_vote', 'type' => 'delete', 'alert_unit' => $sModule, 'alert_action' => 'undoVote'],
+                
+                ['group' => $sModule . '_score_up', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'doVoteUp', 'module_name' => $sModule, 'module_method' => 'get_notifications_score_up', 'module_class' => 'Module'],
+
+                ['group' => $sModule . '_score_down', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'doVoteDown', 'module_name' => $sModule, 'module_method' => 'get_notifications_score_down', 'module_class' => 'Module'],
+
+                ['group' => $sModule . '_fan_added', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'fan_added', 'module_name' => $sModule, 'module_method' => 'get_notifications_fan_added', 'module_class' => 'Module'],
+
+                ['group' => $sModule . '_join_invitation', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'join_invitation_notif', 'module_name' => $sModule, 'module_method' => 'get_notifications_join_invitation', 'module_class' => 'Module'],
+                
+                ['group' => $sModule . '_join_request', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'join_request', 'module_name' => $sModule, 'module_method' => 'get_notifications_join_request', 'module_class' => 'Module', 'module_event_privacy' => $this->_oConfig->CNF['OBJECT_PRIVACY_VIEW_NOTIFICATION_EVENT']],
+                
+                ['group' => $sModule . '_timeline_post_common', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'timeline_post_common', 'module_name' => $sModule, 'module_method' => 'get_notifications_timeline_post_common', 'module_class' => 'Module'],
+                
+                //--- Moderation related: For 'admins'.
+                ['group' => $sModule . '_object_pending_approval', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'pending_approval', 'module_name' => $sModule, 'module_method' => 'get_notifications_post_pending_approval', 'module_class' => 'Module'],
+            ],
+            'settings' => [
+                ['group' => 'vote', 'unit' => $sModule, 'action' => 'doVote', 'types' => $aSettingsTypes],
+
+                ['group' => 'score_up', 'unit' => $sModule, 'action' => 'doVoteUp', 'types' => $aSettingsTypes],
+
+                ['group' => 'score_down', 'unit' => $sModule, 'action' => 'doVoteDown', 'types' => $aSettingsTypes],
+                
+                ['group' => 'fan', 'unit' => $sModule, 'action' => 'fan_added', 'types' => $aSettingsTypes],
+
+                ['group' => 'invite', 'unit' => $sModule, 'action' => 'join_invitation_notif', 'types' => ['personal']],
+
+                ['group' => 'join', 'unit' => $sModule, 'action' => 'join_request', 'types' => $aSettingsTypes],
+
+                ['group' => 'timeline_post', 'unit' => $sModule, 'action' => 'timeline_post_common', 'types' => $aSettingsTypes],
+
+                //--- Moderation related: For 'admins'.
+                ['group' => 'action_required', 'unit' => $sModule, 'action' => 'pending_approval', 'types' => ['personal']],
+            ],
+            'alerts' => [
+                ['unit' => $sModule, 'action' => 'doVote'],
+                ['unit' => $sModule, 'action' => 'undoVote'],
+
+                ['unit' => $sModule, 'action' => 'doVoteUp'],
+                ['unit' => $sModule, 'action' => 'doVoteDown'],
+
+                ['unit' => $sModule, 'action' => 'fan_added'],
+
+                ['unit' => $sModule, 'action' => 'join_invitation_notif'],
+
+                ['unit' => $sModule, 'action' => 'join_request'],
+
+                ['unit' => $sModule, 'action' => 'timeline_post_common'],
+                
+                //--- Moderation related: For 'admins'.
+                ['unit' => $sModule, 'action' => 'pending_approval'],
+            ]
+        ];
+    }
+
+    public function serviceGetNotificationsInsertData($oAlert, $aHandler, $aDataItems)
+    {
+        if($oAlert->sAction != 'join_invitation_notif' || empty($aDataItems) || !is_array($aDataItems))
+            return $aDataItems;
+
+        foreach($aDataItems as $iIndex => $aDataItem)
+            $aDataItems[$iIndex]['object_privacy_view'] = BX_DOL_PG_ALL;
+
+        return $aDataItems;
+    }
+
+    /**
+     * Notification about new invitation to join the group
+     */
+    public function serviceGetNotificationsJoinInvitation($aEvent)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $iContentId = (int)$aEvent['object_id'];
+        $oGroupProfile = BxDolProfile::getInstanceByContentAndType((int)$iContentId, $this->getName());
+        if(!$oGroupProfile)
+            return array();
+
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if(empty($aContentInfo) || !is_array($aContentInfo))
+            return array();
+
+        /*
+         * It's essential that 'object_owner_id' contains invited member profile id.
+         */
+        $oProfile = BxDolProfile::getInstance((int)$aEvent['object_owner_id']);
+        if(!$oProfile)
+            return array();
+
+        /*
+         * Note. Group Profile URL is used for both Entry and Subentry URLs, 
+         * because Subentry URL has higher display priority and notification
+         * should be linked to Group Profile (Group Profile -> Members tab) 
+         * instead of Personal Profile of invited member.
+         */
+        $sEntryUrl = bx_absolute_url(str_replace(BX_DOL_URL_ROOT, '', $oGroupProfile->getUrl()), '{bx_url_root}');
+
+        return array(
+            'entry_sample' => $CNF['T']['txt_sample_single'],
+            'entry_url' => $sEntryUrl,
+            'entry_caption' => $oGroupProfile->getDisplayName(),
+            'subentry_sample' => $oProfile->getDisplayName(),
+            'subentry_url' => $sEntryUrl,
+            'lang_key' => $this->_oConfig->CNF['T']['txt_ntfs_join_invitation']
+        );
+    }
+
+    /**
+     * Notification about new member requst in the group
+     */
+    public function serviceGetNotificationsJoinRequest($aEvent)
+    {
+        return $this->_serviceGetNotification($aEvent, 'join_request', $this->_oConfig->CNF['T']['txt_ntfs_join_request']);
+    }
+
+	/**
+     * Notification about new member in the group
+     */
+    public function serviceGetNotificationsFanAdded($aEvent)
+    {
+        return $this->_serviceGetNotification($aEvent, 'fan_added', $this->_oConfig->CNF['T']['txt_ntfs_fan_added']);
+    }
+
+    protected function _serviceGetNotification($aEvent, $sType, $sLangKey)
+    {
+    	$CNF = &$this->_oConfig->CNF;
+
+        $iContentId = (int)$aEvent['object_id'];
+        $oGroupProfile = BxDolProfile::getInstanceByContentAndType((int)$iContentId, $this->getName());
+        if(!$oGroupProfile)
+            return array();
+
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if(empty($aContentInfo) || !is_array($aContentInfo))
+            return array();
+
+        $oProfile = BxDolProfile::getInstance((int)$aEvent['subobject_id']);
+        if(!$oProfile)
+            return array();
+
+        $iGroupProfileId = $oGroupProfile->id();
+
+        /*
+         * Note. Group Profile URL is used for both Entry and Subentry URLs, 
+         * because Subentry URL has higher display priority and notification
+         * should be linked to Group Profile (Group Profile -> Members tab or Manage page) 
+         * instead of Personal Profile of a member, who performed an action.
+         */
+        if($sType == 'join_request' && !empty($CNF['URL_ENTRY_MANAGE']))
+            $sEntryUrl = bx_absolute_url(BxDolPermalinks::getInstance()->permalink($CNF['URL_ENTRY_MANAGE'], [
+                'profile_id' => $iGroupProfileId
+            ]), '{bx_url_root}');
+        else if(!empty($CNF['URL_ENTRY_FANS']))
+            $sEntryUrl = bx_absolute_url(BxDolPermalinks::getInstance()->permalink($CNF['URL_ENTRY_FANS'], [
+                'profile_id' => $iGroupProfileId
+            ]), '{bx_url_root}');
+        else
+            $sEntryUrl = bx_absolute_url(str_replace(BX_DOL_URL_ROOT, '', $oGroupProfile->getUrl()), '{bx_url_root}');
+
+        return [
+            'entry_sample' => $CNF['T']['txt_sample_single'],
+            'entry_url' => $sEntryUrl,
+            'entry_caption' => $oGroupProfile->getDisplayName(),
+            'entry_author' => $iGroupProfileId,
+            'subentry_sample' => $oProfile->getDisplayName(),
+            'subentry_url' => $sEntryUrl,
+            'lang_key' => $sLangKey
+        ];
+    }
+
+    /**
+     * Data for Reputation module
+     */
+    public function serviceGetReputationData()
+    {
+        $aResult = parent::serviceGetReputationData();
+
+    	$sModule = $this->_aModule['name'];
+
+        if(!empty($aResult['handlers']) && is_array($aResult['handlers']))
+            foreach($aResult['handlers'] as $iKey => $aHandler)
+                if(in_array($aHandler['group'], [$sModule . '_comment', $sModule . '_reaction']))
+                    unset($aResult['handlers'][$iKey]);
+
+        if(!empty($aResult['alerts']) && is_array($aResult['alerts']))
+            foreach($aResult['alerts'] as $iKey => $aAlert)
+                if($aAlert['unit'] == $sModule . '_reactions' || in_array($aAlert['action'], ['commentPost', 'commentRemoved']))
+                    unset($aResult['alerts'][$iKey]);
+
+        return $aResult;
+    }
+
+    /**
+     * Data for Timeline module
+     */
+    public function serviceGetTimelineData()
+    {
+        $aResult = BxBaseModGeneralModule::serviceGetTimelineData();
+
+        $sModule = $this->_aModule['name'];
+        $aResult['handlers'] = array_merge($aResult['handlers'], [
+            ['group' => $sModule . '_object', 'type' => 'update', 'alert_unit' => $sModule, 'alert_action' => 'context_cover_changed'],
+            ['group' => $sModule . '_object', 'type' => 'update', 'alert_unit' => $sModule, 'alert_action' => 'context_cover_deleted']
+        ]);
+        $aResult['alerts'] = array_merge($aResult['alerts'], [
+            ['unit' => $sModule, 'action' => 'context_cover_changed'],
+            ['unit' => $sModule, 'action' => 'context_cover_deleted']
+        ]);
+
+        return $aResult;
+    }
+
+    /**
+     * Entry post for Timeline module
+     */
+    public function serviceGetTimelinePost($aEvent, $aBrowseParams = array())
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $a = parent::serviceGetTimelinePost($aEvent, $aBrowseParams);
+        if($a === false)
+            return false;
+
+        $oGroupProfile = BxDolProfile::getInstanceByContentAndType($aEvent['object_id'], $this->getName());
+        $a['content']['url'] = $oGroupProfile->getUrl();
+        $a['content']['title'] = $oGroupProfile->getDisplayName();
+        
+        $oConnection = BxDolConnection::getObjectInstance($CNF['OBJECT_CONNECTIONS']);
+        if($oConnection)
+            $a['content']['members'] = $oConnection->getConnectedInitiatorsCount($oGroupProfile->id(), true);
+
+        if(isset($aEvent['object_privacy_view']))
+            $a['content']['visibility'] = $aEvent['object_privacy_view'];
+
+        if(isset($CNF['FIELD_PUBLISHED'])) {
+            $aContentInfo = $this->_oDb->getContentInfoById($aEvent['object_id']);
+            if($aContentInfo[$CNF['FIELD_PUBLISHED']] > $a['date'])
+                $a['date'] = $aContentInfo[$CNF['FIELD_PUBLISHED']];
+        }
+
+        return $a;
+    }
+
+
+    // ====== PERMISSION METHODS
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedUsePaidJoin($isPerformAction = false)
+    {
+        // check ACL
+        $aCheck = checkActionModule($this->_iProfileId, 'use paid join', $this->getName(), $isPerformAction);
+        if($aCheck[CHECK_ACTION_RESULT] !== CHECK_ACTION_RESULT_ALLOWED)
+            return $aCheck[CHECK_ACTION_MESSAGE];
+
+        return CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedView ($aDataEntry, $isPerformAction = false)
+    {
+        return $this->serviceCheckAllowedViewForProfile ($aDataEntry, $isPerformAction);
+    }
+
+    public function serviceCheckAllowedViewForProfile ($aDataEntry, $isPerformAction = false, $iProfileId = false)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        
+        $iGroupContentId = (int)$aDataEntry[$CNF['FIELD_ID']];
+
+        $bInvited = false;
+        if(!empty($CNF['TABLE_INVITES'])) {
+            $iGroupProfileId = BxDolProfile::getInstanceByContentAndType($iGroupContentId, $this->getName())->id();
+
+            if(($sKey = bx_get('key')) !== false) {
+                $mixedInvited = $this->isInvited($sKey, $iGroupProfileId);
+                if($mixedInvited === true)
+                    $bInvited = true;
+            }
+            else {
+                $mixedInvited = $this->isInvitedByProfileId($iProfileId ? $iProfileId : bx_get_logged_profile_id(), $iGroupProfileId);
+                if($mixedInvited === true)
+                    $bInvited = true;
+            }
+        }
+
+        if ($this->isFan($iGroupContentId, $iProfileId) || $bInvited)
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        return parent::serviceCheckAllowedViewForProfile ($aDataEntry, $isPerformAction, $iProfileId);
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedCompose(&$aDataEntry, $isPerformAction = false)
+    {
+        if(!$this->isFan($aDataEntry[$this->_oConfig->CNF['FIELD_ID']]))
+            return _t('_sys_txt_access_denied');
+
+        return parent::checkAllowedCompose ($aDataEntry, $isPerformAction);
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedFanAdd(&$aDataEntry, $isPerformAction = false)
+    {
+        $mixedResult = $this->_modGroupsCheckAllowedFanAdd($aDataEntry, $isPerformAction);
+
+        /**
+         * @hooks
+         * @hookdef hook-system-check_allowed_fan_add 'system', 'check_allowed_fan_add' - hook to override the result of checking whether 'fan add' to context action is allowed or not to currently logged in user
+         * - $unit_name - equals `system`
+         * - $action - equals `check_allowed_fan_add`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `module` - [string] module name
+         *      - `content_info` - [array] context info array as key&value pairs
+         *      - `profile_id` - [int] currently logged in profile id to be checked the availability of the action to
+         *      - `override_result` - [string] or [int] by ref, check action result, can be overridden in hook processing. Return string with an error if action isn't allowed or CHECK_ACTION_RESULT_ALLOWED, @see BxDolAcl
+         * @hook @ref hook-system-check_allowed_fan_add
+         */
+        bx_alert('system', 'check_allowed_fan_add', 0, 0, [
+            'module' => $this->getName(), 
+            'content_info' => $aDataEntry, 
+            'profile_id' => bx_get_logged_profile_id(), 
+            'override_result' => &$mixedResult
+        ]);
+
+        return $mixedResult;
+    }
+
+    public function _modGroupsCheckAllowedFanAdd (&$aDataEntry, $isPerformAction = false)
+    {
+        if ($this->isFan($aDataEntry[$this->_oConfig->CNF['FIELD_ID']]) || !isLogged())
+            return _t('_sys_txt_access_denied');
+
+        return $this->_checkAllowedConnect ($aDataEntry, $isPerformAction, $this->_oConfig->CNF['OBJECT_CONNECTIONS'], false, false);
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedFanRemove (&$aDataEntry, $isPerformAction = false)
+    {
+        if (CHECK_ACTION_RESULT_ALLOWED === $this->_checkAllowedConnect ($aDataEntry, $isPerformAction, $this->_oConfig->CNF['OBJECT_CONNECTIONS'], false, true, true))
+            return CHECK_ACTION_RESULT_ALLOWED;
+        return $this->_checkAllowedConnect ($aDataEntry, $isPerformAction, $this->_oConfig->CNF['OBJECT_CONNECTIONS'], false, true, false);
+    }
+
+    protected function _checkAllowedActionByFan($sAction, $aDataEntry, $iProfileId = 0)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $bRoles = $this->_oConfig->isRoles();
+        if(empty($iProfileId))
+            $iProfileId = bx_get_logged_profile_id();
+
+        $oGroupProfile = BxDolProfile::getInstanceByContentAndType($aDataEntry[$CNF['FIELD_ID']], $this->getName());
+        if(!$oGroupProfile)
+            return $sAction == BX_BASE_MOD_GROUPS_ACTION_DELETE ? CHECK_ACTION_RESULT_ALLOWED : _t('_sys_txt_not_found');
+
+        $iGroupProfileId = $oGroupProfile->id();
+
+        if(!$bRoles && $this->_oDb->isAdmin($iGroupProfileId, $iProfileId, $aDataEntry))
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        if($bRoles && $this->isAllowedActionByRole($sAction, $aDataEntry, $iGroupProfileId, $iProfileId))
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        return _t('_sys_txt_access_denied');
+    }
+
+    public function isAllowedActionByRole($mAction, $aDataEntry, $iGroupProfileId, $iProfileId)
+    {
+        $iProfileRole = $this->_oDb->getRole($iGroupProfileId, $iProfileId);
+
+        if (is_array($mAction)) {
+            $sAction = $mAction['action'];
+            $sActionModule = $mAction['module'];
+        } else {
+            $sAction = $mAction;
+            $sActionModule = $this->getName();
+        }
+
+        $bResult = false;
+        if($iProfileId)
+            $bResult = $this->isAllowedModuleActionByRole($sActionModule, $sAction, $iProfileRole);
+
+        // in case neither of the profile's roles are having permissions set explicitly then fallback to an old way
+        if ($bResult === NULL) {
+            $bResult = false;
+            if($this->isFanByGroupProfileId($iGroupProfileId)) {
+                switch ($sAction) {
+                    case BX_BASE_MOD_GROUPS_ACTION_DELETE:
+                    case BX_BASE_MOD_GROUPS_ACTION_EDIT:
+                    case BX_BASE_MOD_GROUPS_ACTION_CHANGE_SETTINGS:
+                    case BX_BASE_MOD_GROUPS_ACTION_CHANGE_COVER:
+                    case BX_BASE_MOD_GROUPS_ACTION_MANAGE_ROLES:
+                        if($this->isRole($iProfileRole, BX_BASE_MOD_GROUPS_ROLE_ADMINISTRATOR)) 
+                            $bResult = true;
+                        break;
+
+                    case BX_BASE_MOD_GROUPS_ACTION_MANAGE_FANS:
+                    case BX_BASE_MOD_GROUPS_ACTION_INVITE:
+                    case BX_BASE_MOD_GROUPS_ACTION_EDIT_CONTENT:
+                    case BX_BASE_MOD_GROUPS_ACTION_DELETE_CONTENT:
+                    case BX_BASE_MOD_GROUPS_ACTION_TIMELINE_POST_PIN:
+                        if($this->isRole($iProfileRole, BX_BASE_MOD_GROUPS_ROLE_ADMINISTRATOR) || $this->isRole($iProfileRole, BX_BASE_MOD_GROUPS_ROLE_MODERATOR)) 
+                            $bResult = true;
+                        break;
+
+                    default:
+                        $bResult = true;
+                }
+            }
+        }
+
+        // in case current user is not allowed to edit/delete this group then
+        // if it is a subgroup inside a context then give to the admin roles of a parent context the ability to edit/delete that subgroup.
+        if (
+            !$bResult &&
+            ($sAction == BX_BASE_MOD_GROUPS_ACTION_EDIT || $sAction == BX_BASE_MOD_GROUPS_ACTION_DELETE) &&
+            isset($this->_oConfig->CNF['FIELD_ALLOW_VIEW_TO']) &&
+            $this->_oConfig->CNF['FIELD_ALLOW_VIEW_TO'] &&
+            isset($aDataEntry[$this->_oConfig->CNF['FIELD_ALLOW_VIEW_TO']]) &&
+            $aDataEntry[$this->_oConfig->CNF['FIELD_ALLOW_VIEW_TO']] < 0)
+        {
+            $iParentContextProfileId = -$aDataEntry[$this->_oConfig->CNF['FIELD_ALLOW_VIEW_TO']];
+            $oParentContext = BxDolProfile::getInstance($iParentContextProfileId);
+            if ($oParentContext) {
+                $sCheckAction = $sAction == BX_BASE_MOD_GROUPS_ACTION_EDIT ? BX_BASE_MOD_GROUPS_ACTION_EDIT_CONTENT : BX_BASE_MOD_GROUPS_ACTION_DELETE_CONTENT;
+                $aParentDataEntry = bx_srv($oParentContext->getModule(), 'get_info', [$oParentContext->getContentId(), false]);
+                return $this->isAllowedActionByRole(['action' => $sCheckAction, 'module' => $oParentContext->getModule()], $aParentDataEntry, $iParentContextProfileId, $iProfileId);
+            }
+        }
+
+        /**
+         * @hooks
+         * @hookdef hook-system-check_allowed_action_by_role 'system', 'check_allowed_action_by_role' - hook to override the result of checking whether an action is allowed or not to context member by his role in the context
+         * - $unit_name - equals `system`
+         * - $action - equals `check_allowed_action_by_role`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `module` - [string] module name
+         *      - `multi_roles` - [boolean] whether multi roles are enabled in context or not
+         *      - `action` - [string] action to be checked
+         *      - `action_module` - [string] module name which the action belongs to
+         *      - `content_profile_id` - [int] context profile id
+         *      - `content_info` - [array] context info array as key&value pairs
+         *      - `profile_id` - [int] profile id to be checked the availability of the action to
+         *      - `profile_role` - [int] profile role in the context
+         *      - `override_result` - [boolean] by ref, check action result, can be overridden in hook processing.
+         * @hook @ref hook-system-check_allowed_action_by_role
+         */
+        bx_alert('system', 'check_allowed_action_by_role', 0, 0, [
+            'module' => $this->getName(), 
+            'multi_roles' => $this->_oConfig->isMultiRoles(),
+            'action' => $sAction,
+            'action_module' => $sActionModule,
+            'content_profile_id' => $iGroupProfileId, 
+            'content_info' => $aDataEntry, 
+            'profile_id' => $iProfileId, 
+            'profile_role' => $iProfileRole,
+            'override_result' => &$bResult
+        ]);
+
+        return $bResult;
+    }
+
+    public function isAllowedModuleActionByRole($sModule, $sAction, $iProfileRole)
+    {
+        static $aRoles;
+
+        if (!$aRoles && isset($this->_oConfig->CNF['OBJECT_PRE_LIST_ROLES']) && !empty($this->_oConfig->CNF['OBJECT_PRE_LIST_ROLES']))
+            $aRoles = BxBaseFormView::getDataItems($this->_oConfig->CNF['OBJECT_PRE_LIST_ROLES'], true, BX_DATA_VALUES_ALL);
+
+        if ($aRoles) {
+            foreach ($aRoles as $iRole => $aRoleData) {
+                if ($iRole == 0 && $iProfileRole == 0 || $iRole > 0 && $this->isRole($iProfileRole, $iRole)) {
+                    $mPermissions = isset($aRoles[$iRole]) && isset($aRoles[$iRole]['Data']) && !empty($aRoles[$iRole]['Data']) ? unserialize($aRoles[$iRole]['Data']) : false;
+                    if ($mPermissions && isset($mPermissions[$sModule])) {
+                        return isset($mPermissions[$sModule][$sAction]) && $mPermissions[$sModule][$sAction];
+                    }
+                }
+            }
+        }
+
+        return NULL;
+    }
+
+    public function isAllowedModuleActionByProfile($iContentId, $sPostModule, $sAction, $iProfileId = 0) {
+        if (!$iProfileId) $iProfileId = bx_get_logged_profile_id();
+
+        if ($iProfileId && $this->isFan($iContentId, $iProfileId)) {
+            $sModuleName = $this->getName();
+            $oGroupProfile = BxDolProfile::getInstanceByContentAndType($iContentId, $sModuleName);
+            $aDataEntry = $this->_oDb->getContentInfoById($iContentId);
+            $bResult = $this->isAllowedActionByRole(['action' => $sAction, 'module' => $sPostModule], $aDataEntry, $oGroupProfile->id(), $iProfileId);
+
+            if ($bResult === true) return CHECK_ACTION_RESULT_ALLOWED;
+            if ($bResult === false) return _t('_sys_txt_access_denied');
+        }
+
+        return NULL; //undefined, because the profile is either not a fan or his role is not having permissions defined. So process the default way then.
+    }
+
+    public function isRole($iProfileRole, $iRole)
+    {
+        if(!$this->_oConfig->isMultiRoles())
+            return $iProfileRole == $iRole;
+        else 
+            return $iProfileRole & (1 << ($iRole - 1));
+    }
+
+    public function serviceIsRole($iProfileRole, $iRole)
+    {
+        return $this->isRole($iProfileRole, $iRole);
+    }
+
+    public function checkAllowedManageFans($mixedDataEntry, $isPerformAction = false)
+    {
+        $aDataEntry = array();
+        if(!is_array($mixedDataEntry)) {
+            $oGroupProfile = BxDolProfile::getInstance((int)$mixedDataEntry);
+            if($oGroupProfile && $this->getName() == $oGroupProfile->getModule())
+                $aDataEntry = $this->_oDb->getContentInfoById($oGroupProfile->getContentId());
+        }
+        else
+            $aDataEntry = $mixedDataEntry;
+
+        if($this->_checkAllowedActionByFan(BX_BASE_MOD_GROUPS_ACTION_MANAGE_FANS, $aDataEntry) === CHECK_ACTION_RESULT_ALLOWED)
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        return parent::checkAllowedEdit($aDataEntry, $isPerformAction);
+    }
+
+    public function checkAllowedManageAdmins($mixedDataEntry, $isPerformAction = false)
+    {
+        $aDataEntry = array();
+        if(!is_array($mixedDataEntry)) {
+            $oGroupProfile = BxDolProfile::getInstance((int)$mixedDataEntry);
+            if($oGroupProfile && $this->getName() == $oGroupProfile->getModule())
+                $aDataEntry = $this->_oDb->getContentInfoById($oGroupProfile->getContentId());
+        }
+        else
+            $aDataEntry = $mixedDataEntry;
+
+        if($this->_checkAllowedActionByFan(BX_BASE_MOD_GROUPS_ACTION_MANAGE_ROLES, $aDataEntry) === CHECK_ACTION_RESULT_ALLOWED)
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        return parent::checkAllowedEdit($aDataEntry, $isPerformAction);
+    }
+
+    public function checkAllowedEdit($aDataEntry, $isPerformAction = false)
+    {
+        if($this->_checkAllowedActionByFan(BX_BASE_MOD_GROUPS_ACTION_EDIT, $aDataEntry) === CHECK_ACTION_RESULT_ALLOWED)
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        return parent::checkAllowedEdit($aDataEntry, $isPerformAction);
+    }
+
+    public function checkAllowedInvite($aDataEntry, $isPerformAction = false)
+    {
+        if($this->_checkAllowedActionByFan(BX_BASE_MOD_GROUPS_ACTION_INVITE, $aDataEntry) === CHECK_ACTION_RESULT_ALLOWED)
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        return parent::checkAllowedEdit($aDataEntry, $isPerformAction);
+    }
+
+    public function checkAllowedChangeCover($aDataEntry, $isPerformAction = false)
+    {
+        if($this->_checkAllowedActionByFan(BX_BASE_MOD_GROUPS_ACTION_CHANGE_COVER, $aDataEntry) === CHECK_ACTION_RESULT_ALLOWED)
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        return parent::checkAllowedChangeCover($aDataEntry, $isPerformAction);
+    }
+
+    public function checkAllowedChangeSettings($aDataEntry, $isPerformAction = false)
+    {
+        if($this->_checkAllowedActionByFan(BX_BASE_MOD_GROUPS_ACTION_CHANGE_SETTINGS, $aDataEntry) === CHECK_ACTION_RESULT_ALLOWED)
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        return parent::checkAllowedChangeSettings($aDataEntry, $isPerformAction);
+    }
+
+    public function checkAllowedDelete(&$aDataEntry, $isPerformAction = false)
+    {
+        if($this->_checkAllowedActionByFan(BX_BASE_MOD_GROUPS_ACTION_DELETE, $aDataEntry) === CHECK_ACTION_RESULT_ALLOWED)
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        return parent::checkAllowedDelete($aDataEntry, $isPerformAction);
+    }
+
+    public function checkAllowedJoin(&$aDataEntry, $isPerformAction = false)
+    {
+        if (bx_get('key')){
+            $sKey = bx_get('key');
+            $oGroupProfile = BxDolProfile::getInstanceByContentAndType($aDataEntry[$this->_oConfig->CNF['FIELD_ID']], $this->getName());
+            $aData = $this->_oDb->getInviteByKey($sKey, $oGroupProfile->id());
+            if (isset($aData['invited_profile_id']) && $aData['invited_profile_id'] == bx_get_logged_profile_id()){
+                return CHECK_ACTION_RESULT_ALLOWED;
+            }
+        }   
+        return _t('_sys_txt_access_denied');
+    }   
+
+    public function checkAllowedSubscribeAdd(&$aDataEntry, $isPerformAction = false)
+    {
+        $mixedResult = $this->_modGroupsCheckAllowedSubscribeAdd($aDataEntry, $isPerformAction);
+
+        /**
+         * @hooks
+         * @hookdef hook-system-check_allowed_subscribe_add 'system', 'check_allowed_subscribe_add' - hook to override the result of checking whether currently logged in user can subscribe (follow) the context or not
+         * - $unit_name - equals `system`
+         * - $action - equals `check_allowed_subscribe_add`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `module` - [string] module name
+         *      - `content_info` - [array] context info array as key&value pairs
+         *      - `profile_id` - [int] currently logged in profile id to be checked the availability of the action to
+         *      - `override_result` - [string] or [int] by ref, check action result, can be overridden in hook processing. Return string with an error if action isn't allowed or CHECK_ACTION_RESULT_ALLOWED, @see BxDolAcl
+         * @hook @ref hook-system-check_allowed_subscribe_add
+         */
+        bx_alert('system', 'check_allowed_subscribe_add', 0, 0, [
+            'module' => $this->getName(), 
+            'content_info' => $aDataEntry, 
+            'profile_id' => bx_get_logged_profile_id(), 
+            'override_result' => &$mixedResult
+        ]);
+
+        return $mixedResult;
+    }
+
+    /**
+     * Note. Is mainly needed for internal usage. Access level is 'public' to allow outer calls from alerts.
+     */
+    public function _modGroupsCheckAllowedSubscribeAdd(&$aDataEntry, $isPerformAction = false)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!$this->isFan($aDataEntry[$CNF['FIELD_ID']]) && (!isset($CNF['PARAM_SBS_WO_JOIN']) || getParam($CNF['PARAM_SBS_WO_JOIN']) != 'on'))
+            return _t('_sys_txt_access_denied');
+
+        return parent::_modProfileCheckAllowedSubscribeAdd($aDataEntry, $isPerformAction);
+    }
+
+    /**
+     * @deprecated since version 11.0.3 and can be removed in the next version.
+     */
+    public function _checkAllowedSubscribeAdd (&$aDataEntry, $isPerformAction = false)
+    {
+        return parent::checkAllowedSubscribeAdd ($aDataEntry, $isPerformAction);
+    }
+    
+    public function doAudit($iGroupProfileId, $iFanId, $sAction)
+    {
+        $oProfile = BxDolProfile::getInstance($iFanId);
+        
+        $iContentId = $oProfile->getContentId();
+        $sModule = $oProfile->getModule();
+        $oModule = BxDolModule::getInstance($sModule);
+        if (BxDolRequest::serviceExists($sModule, 'act_as_profile') && BxDolService::call($sModule, 'act_as_profile') && $oModule->_oConfig){
+            $CNF = $oModule->_oConfig->CNF;
+
+            $aContentInfo = BxDolRequest::serviceExists($sModule, 'get_all') ? BxDolService::call($sModule, 'get_all', array(array('type' => 'id', 'id' => $iContentId))) : array();
+        
+            $AuditParams = array(
+                'content_title' => (isset($CNF['FIELD_TITLE']) && isset($aContentInfo[$CNF['FIELD_TITLE']])) ? $aContentInfo[$CNF['FIELD_TITLE']] : '',
+                'context_profile_id' => $iGroupProfileId,
+                'context_profile_title' => BxDolProfile::getInstance($iGroupProfileId)->getDisplayName()
+            );
+        
+            bx_audit(
+                $iContentId, 
+                $sModule, 
+                $sAction,  
+                $AuditParams
+            );
+        }
+    }
+    
+    protected function _checkAllowedConnect (&$aDataEntry, $isPerformAction, $sObjConnection, $isMutual, $isInvertResult, $isSwap = false)
+    {
+        $sResult = $this->checkAllowedView($aDataEntry);
+
+        $oPrivacy = BxDolPrivacy::getObjectInstance($this->_oConfig->CNF['OBJECT_PRIVACY_VIEW']);
+
+        // when context is in another context
+        if ($aDataEntry[$this->_oConfig->CNF['FIELD_ALLOW_VIEW_TO']] < 0 && getParam('sys_check_fan_in_parent_context')) {
+            $oParent = BxDolProfile::getInstance(abs($aDataEntry[$this->_oConfig->CNF['FIELD_ALLOW_VIEW_TO']]));
+            $oModule = $oParent ? BxDolModule::getInstance($oParent->getModule()) : null;
+            if ($oModule && method_exists($oModule, 'isFan') && !$oModule->isFan($oParent->getContentId()))
+                return _t('_sys_txt_access_denied');
+        }
+
+        // if profile view isn't allowed but visibility is in partially visible groups 
+        // then display buttons to connect (befriend, join) to profile, 
+        // if other conditions (in parent::_checkAllowedConnect) are met as well
+        if (CHECK_ACTION_RESULT_ALLOWED !== $sResult && !in_array($aDataEntry[$this->_oConfig->CNF['FIELD_ALLOW_VIEW_TO']], array_merge($oPrivacy->getPartiallyVisiblePrivacyGroups(), array('s'))))
+            return $sResult;
+
+        return parent::_checkAllowedConnect ($aDataEntry, $isPerformAction, $sObjConnection, $isMutual, $isInvertResult, $isSwap);
+    }
+
+
+    // ====== COMMON METHODS
+    public function onUpdateImage($iContentId, $sFiledName, $sFiledValue, $iProfileId = 0)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+
+        $aField2Method = [
+            $CNF['FIELD_PICTURE'] => 'picture',
+            $CNF['FIELD_COVER'] => 'cover',
+        ];
+
+        if(!empty($aField2Method[$sFiledName]))
+            /**
+             * @hooks
+             * @hookdef hook-bx_base_groups-context_picture_changed '{module_name}', 'context_picture_changed' - hook after context picture was changed
+             * - $unit_name - module name
+             * - $action - equals `context_picture_changed`
+             * - $object_id - context id
+             * - $sender_id - profile id who performed the action
+             * - $extra_params - array of additional params with the following array keys:
+             *      - `status` - [string] context status
+             *      - `status_admin` - [string] context admin status
+             *      - `privacy_view` - [int] or [string] privacy for view context action, @see BxDolPrivacy
+             *      - `cf` - [int] context's audience filter value
+             * @hook @ref hook-bx_base_groups-context_picture_changed
+             */
+            /**
+             * @hooks
+             * @hookdef hook-bx_base_groups-context_cover_changed '{module_name}', 'context_cover_changed' - hook after context cover was changed
+             * It's equivalent to @ref hook-bx_base_groups-context_picture_changed 
+             * @hook @ref hook-bx_base_groups-context_cover_changed
+             */
+            bx_alert($this->getName(), 'context_' . $aField2Method[$sFiledName] . '_changed', $iContentId, $iProfileId, $this->_alertParams($aContentInfo));
+    }
+
+    protected function _alertParams($aContentInfo)
+    {
+        $aParams = parent::_alertParams($aContentInfo);
+
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!empty($CNF['FIELD_CF']) && isset($aContentInfo[$CNF['FIELD_CF']]))
+            $aParams['cf'] = $aContentInfo[$CNF['FIELD_CF']];
+
+        return $aParams;
+    }
+
+    public function alertAfterAdd($aContentInfo)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $iId = (int)$aContentInfo[$CNF['FIELD_ID']];
+        $iAuthorId = (int)$aContentInfo[$CNF['FIELD_AUTHOR']];
+
+        $sAction = 'added';
+        if(isset($CNF['FIELD_STATUS_ADMIN']) && isset($aContentInfo[$CNF['FIELD_STATUS_ADMIN']]) && $aContentInfo[$CNF['FIELD_STATUS_ADMIN']] == BX_BASE_MOD_GENERAL_STATUS_PENDING)
+            $sAction = 'deferred';        
+
+        $sModule = $this->getName();
+        $aParams = $this->_alertParams($aContentInfo);
+        /**
+         * @hooks
+         * @hookdef hook-system-prepare_alert_params 'system', 'prepare_alert_params' - hook to override alert (hook) params
+         * - $unit_name - equals `system`
+         * - $action - equals `prepare_alert_params`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `unit` - [string] unit name
+         *      - `action` - [string] by ref, action, can be overridden in hook processing
+         *      - `object_id` - [int] by ref, object id, can be overridden in hook processing
+         *      - `sender_id` - [int] by ref, action performer profile id, can be overridden in hook processing
+         *      - `extras` - [array] by ref, extra params array as key&value pairs, can be overridden in hook processing
+         * @hook @ref hook-system-prepare_alert_params
+         */
+        bx_alert('system', 'prepare_alert_params', 0, 0, [
+            'unit'=> $sModule, 
+            'action' => &$sAction, 
+            'object_id' => &$iId, 
+            'sender_id' => &$iAuthorId, 
+            'extras' => &$aParams
+        ]);
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_groups-added '{module_name}', 'added' - hook after context was added (published)
+         * - $unit_name - module name
+         * - $action - equals `added`
+         * - $object_id - context id
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `status` - [string] context status
+         *      - `status_admin` - [string] context admin status
+         *      - `privacy_view` - [int] or [string] privacy for view context action, @see BxDolPrivacy
+         *      - `cf` - [int] context's audience filter value
+         * @hook @ref hook-bx_base_groups-added
+         */
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_groups-deferred '{module_name}', 'deferred' - hook after context was added with pending approval status
+         * It's equivalent to @ref hook-bx_base_groups-added
+         * @hook @ref hook-bx_base_groups-deferred
+         */
+        bx_alert($sModule, $sAction, $iId, false, $aParams);
+
+        $this->_processModerationNotifications($aContentInfo);
+    }
+
+    public function addFollower ($iProfileId1, $iProfileId2)
+    {
+        $oConnectionFollow = BxDolConnection::getObjectInstance('sys_profiles_subscriptions');
+        if($oConnectionFollow && !$oConnectionFollow->isConnected($iProfileId1, $iProfileId2)){
+            $oConnectionFollow->addConnection($iProfileId1, $iProfileId2);
+            return true;
+        }
+        return false;
+    }
+    
+    public function isFan ($iContentId, $iProfileId = false) 
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $oGroupProfile = BxDolProfile::getInstanceByContentAndType($iContentId, $this->getName());
+        if($oGroupProfile && isset($CNF['OBJECT_CONNECTIONS']))
+            return ($oConnection = BxDolConnection::getObjectInstance($CNF['OBJECT_CONNECTIONS'])) && $oConnection->isConnected($iProfileId ? $iProfileId : bx_get_logged_profile_id(), $oGroupProfile->id(), true);
+
+        return false;
+    }
+
+    public function isFanByGroupProfileId ($iGroupProfileId, $iProfileId = false) 
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $oGroupProfile = BxDolProfile::getInstance($iGroupProfileId);
+        if($oGroupProfile && isset($CNF['OBJECT_CONNECTIONS']))
+            return ($oConnection = BxDolConnection::getObjectInstance($CNF['OBJECT_CONNECTIONS'])) && $oConnection->isConnected($iProfileId ? $iProfileId : bx_get_logged_profile_id(), $oGroupProfile->id(), true);
+
+        return false;
+    }
+
+    public function isInvited ($sKey, $iGroupProfileId) 
+    {
+        $CNF = &$this->_oConfig->CNF;
+        $aData = $this->_oDb->getInviteByKey($sKey,  $iGroupProfileId);
+        if (!isset($aData['invited_profile_id']))
+            return _t($CNF['T']['txt_invitation_popup_error_invitation_absent']);
+        
+        if ($aData['invited_profile_id'] != bx_get_logged_profile_id())
+            return _t($CNF['T']['txt_invitation_popup_error_wrong_user']);
+        
+        return true;
+    }
+
+    public function isInvitedByProfileId ($iProfileId, $iGroupProfileId) 
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $aData = $this->_oDb->getInviteByInvited($iProfileId,  $iGroupProfileId);
+        if (!isset($aData['invited_profile_id']))
+            return _t($CNF['T']['txt_invitation_popup_error_invitation_absent']);
+
+        if ($aData['invited_profile_id'] != bx_get_logged_profile_id())
+            return _t($CNF['T']['txt_invitation_popup_error_wrong_user']);
+
+        return true;
+    }
+
+    public function serviceIsInvited($iGroupProfileId, $iProfileId = false, $sKey = '')
+    {
+        if(!$iProfileId)
+            $iProfileId = bx_get_logged_profile_id();
+
+        if(empty($sKey) && ($sKey = bx_get('key')) !== false)
+            $sKey = bx_process_input($sKey);
+
+        $mixedInvited = false;
+        if(!empty($sKey))
+            $mixedInvited = $this->isInvited($sKey, $iGroupProfileId);
+        else if($iProfileId !== false)
+            $mixedInvited = $this->isInvitedByProfileId($iProfileId, $iGroupProfileId);
+
+        return $mixedInvited === true;
+    }
+
+    public function serviceIsNotInvited($iGroupProfileId, $iProfileId = false, $sKey = '')
+    {
+        return !$this->serviceIsInvited($iGroupProfileId, $iProfileId, $sKey);
+    }
+
+    public function serviceGetInvitedKey($iGroupProfileId, $iProfileId = false)
+    {
+        $sKey = '';
+        if(($sKey = bx_get('key')) !== false)
+            $sKey = bx_process_input($sKey);
+
+        if(!$sKey) {
+            if(!$iProfileId)
+                $iProfileId = bx_get_logged_profile_id();
+
+            if($iProfileId !== false) {
+                $aInvite = $this->_oDb->getInviteByInvited($iProfileId, $iGroupProfileId);
+                if(!empty($aInvite) && is_array($aInvite))
+                    $sKey = $aInvite['key'];
+            }
+        }
+
+        return $sKey;
+    }
+
+    public function getRole($iGroupProfileId, $iFanProfileId)
+    {
+        if(!$this->isFanByGroupProfileId($iGroupProfileId, $iFanProfileId))
+            return false;
+
+        return $this->_oDb->getRole($iGroupProfileId, $iFanProfileId);
+    }
+
+    public function setRole($iGroupProfileId, $iFanProfileId, $mixedRole, $mixedPeriod = false, $sOrder = '')
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!isset($CNF['OBJECT_CONNECTIONS']))
+            return false;
+
+        $oConnection = BxDolConnection::getObjectInstance($CNF['OBJECT_CONNECTIONS']);
+        $oGroupProfile = BxDolProfile::getInstance($iGroupProfileId);
+        if(!$oConnection || !$oGroupProfile)
+            return false;
+
+        if(!$oConnection->isConnected($iFanProfileId, $iGroupProfileId, true) && !$oConnection->addConnection($iFanProfileId, $iGroupProfileId))
+            return false;
+
+        if(!$this->_oDb->setRole($iGroupProfileId, $iFanProfileId, $mixedRole, $mixedPeriod, $sOrder))
+            return false;
+
+        $this->onSetRole($iGroupProfileId, $iFanProfileId, $mixedRole);
+
+        return true;
+    }
+
+    public function onSetRole($iGroupProfileId, $iFanProfileId, $mixedRole)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $iProfileId = bx_get_logged_profile_id();
+        $oGroupProfile = BxDolProfile::getInstance($iGroupProfileId);
+        $aGroupProfileInfo = $this->_oDb->getContentInfoById((int)$oGroupProfile->getContentId());
+        $aRoles = BxDolFormQuery::getDataItems($CNF['OBJECT_PRE_LIST_ROLES']);
+
+        // notify about admin status
+        if(!empty($CNF['EMAIL_FAN_SET_ROLE']) && $iFanProfileId != $iProfileId) {
+            $aSetRoles = is_array($mixedRole) ? $mixedRole : [$mixedRole];
+            $aRolesNames = [];
+            foreach ($aSetRoles as $iRole)
+                $aRolesNames[] = $aRoles[(int)$iRole];
+
+            sendMailTemplate($CNF['EMAIL_FAN_SET_ROLE'], 0, $iFanProfileId, array(
+                'EntryUrl' => $oGroupProfile->getUrl(),
+                'EntryTitle' => $oGroupProfile->getDisplayName(),
+                'Role' => implode(', ', $aRolesNames),
+            ), BX_EMAIL_NOTIFY);
+        }
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_groups-set_role '{module_name}', 'set_role' - hook after 'set role' action was applied to context member
+         * - $unit_name - module name
+         * - $action - equals `set_role`
+         * - $object_id - context id
+         * - $sender_id - context profile id
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `object_author_id` - [int] context profile id
+         *      - `performer_id` - [int] performer profile id
+         *      - `fan_id` - [int] context member profile id
+         *      - `content` - [array] context info array as key&value pairs
+         *      - `role` - [int] or [array] role or an array of roles to be set
+         *      - `group_profile` - [int] context profile id
+         *      - `profile` - [int] performer profile id
+         * @hook @ref hook-bx_base_groups-set_role
+         */
+        bx_alert($this->getName(), 'set_role', $aGroupProfileInfo[$CNF['FIELD_ID']], $iGroupProfileId, array(
+            'object_author_id' => $iGroupProfileId,
+            'performer_id' => $iProfileId, 
+            'fan_id' => $iFanProfileId,
+
+            'content' => $aGroupProfileInfo, 
+            'role' => $mixedRole,
+
+            'group_profile' => $iGroupProfileId, 
+            'profile' => $iProfileId
+        ));
+
+        $this->doAudit($iGroupProfileId, $iFanProfileId, '_sys_audit_action_group_role_changed');
+    }
+
+    public function unsetRole($iGroupProfileId, $iFanProfileId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!isset($CNF['OBJECT_CONNECTIONS']))
+            return false;
+
+        $oConnection = BxDolConnection::getObjectInstance($CNF['OBJECT_CONNECTIONS']);
+        $oGroupProfile = BxDolProfile::getInstance($iGroupProfileId);
+        if(!$oConnection || !$oGroupProfile)
+            return false;
+
+        if($oConnection->isConnected($iFanProfileId, $iGroupProfileId, true) && !$oConnection->removeConnection($iFanProfileId, $iGroupProfileId))
+            return false;
+
+        $iRole = $this->_oDb->getRole($iGroupProfileId, $iFanProfileId);
+
+        if(!$this->_oDb->unsetRole($iGroupProfileId, $iFanProfileId))
+            return false;
+
+        $this->onUnsetRole($iGroupProfileId, $iFanProfileId, $iRole);
+
+        return true;
+    }
+
+    public function onUnsetRole($iGroupProfileId, $iFanProfileId, $iRole)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $iProfileId = bx_get_logged_profile_id();
+        $oGroupProfile = BxDolProfile::getInstance($iGroupProfileId);
+        $aGroupProfileInfo = $this->_oDb->getContentInfoById((int)$oGroupProfile->getContentId());
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_groups-set_role '{module_name}', 'set_role' - hook after 'set role' action was applied to context member
+         * - $unit_name - module name
+         * - $action - equals `set_role`
+         * - $object_id - context id
+         * - $sender_id - context profile id
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `object_author_id` - [int] context profile id
+         *      - `performer_id` - [int] performer profile id
+         *      - `fan_id` - [int] context member profile id
+         *      - `content` - [array] context info array as key&value pairs
+         *      - `role` - [int] or [array] role or an array of roles to be set
+         *      - `group_profile` - [int] context profile id
+         *      - `profile` - [int] performer profile id
+         * @hook @ref hook-bx_base_groups-set_role
+         */
+        bx_alert($this->getName(), 'set_role', $aGroupProfileInfo[$CNF['FIELD_ID']], $iGroupProfileId, array(
+            'object_author_id' => $iGroupProfileId,
+            'performer_id' => $iProfileId, 
+            'fan_id' => $iFanProfileId,
+
+            'content' => $aGroupProfileInfo,
+            'role' => $iRole,
+
+            'group_profile' => $iGroupProfileId, 
+            'profile' => $iProfileId
+        ));
+
+        $this->doAudit($iGroupProfileId, $iFanProfileId, '_sys_audit_action_group_role_changed');
+    }
+
+    public function getGroupsByFan($iProfileId, $mixedRole = false)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!isset($CNF['OBJECT_CONNECTIONS']))
+            return false;
+
+        if($mixedRole === false)
+            $mixedRole = BX_BASE_MOD_GROUPS_ROLE_COMMON;
+
+        if(!is_array($mixedRole))
+            $mixedRole = [$mixedRole];
+
+        $aResult = [];
+        foreach($mixedRole as $iRole) {
+            switch($iRole) {
+                case BX_BASE_MOD_GROUPS_ROLE_COMMON:
+                    $aIds = BxDolConnection::getObjectInstance($CNF['OBJECT_CONNECTIONS'])->getConnectedContent($iProfileId);
+                    break;
+
+                default:
+                    $aIds = $this->_oDb->getRoles([
+                        'type' => 'group_pids_by_fan_id', 
+                        'fan_id' => $iProfileId,
+                        'role' => $iRole
+                    ]);
+            }
+
+            $aResult = array_merge($aResult, $aIds);
+        }
+
+        return $aResult;
+    }
+
+    public function getMenuItemTitleByConnection($sConnection, $sAction, $iContentProfileId, $iInitiatorProfileId = 0)
+    {
+        $CNF = $this->_oConfig->getCNF();
+
+        $aResult = parent::getMenuItemTitleByConnection($sConnection, $sAction, $iContentProfileId, $iInitiatorProfileId);
+        if(!empty($aResult))
+            return $aResult;
+
+        $oConnection = BxDolConnection::getObjectInstance($sConnection);
+        if(!$oConnection)
+            return false;
+
+        if(!$iInitiatorProfileId)
+            $iInitiatorProfileId = bx_get_logged_profile_id();
+
+        $aResult = [];
+        if($oConnection->isConnectedNotMutual($iInitiatorProfileId, $iContentProfileId))
+            $aResult = [
+                'add' => _t(!empty($CNF['T']['menu_item_title_sm_join_requested']) ? $CNF['T']['menu_item_title_sm_join_requested'] : '_sys_menu_item_title_sm_join_requested'),
+                'remove' => _t(!empty($CNF['T']['menu_item_title_sm_leave_cancel']) ? $CNF['T']['menu_item_title_sm_leave_cancel'] : '_sys_menu_item_title_sm_leave_cancel'),
+            ];
+        else if($oConnection->isConnectedNotMutual($iContentProfileId, $iInitiatorProfileId))
+            $aResult = [
+                'add' => _t(!empty($CNF['T']['menu_item_title_sm_join_confirm']) ? $CNF['T']['menu_item_title_sm_join_confirm'] : '_sys_menu_item_title_sm_join_confirm'),
+                'remove' => _t(!empty($CNF['T']['menu_item_title_sm_leave_reject']) ? $CNF['T']['menu_item_title_sm_leave_reject'] : '_sys_menu_item_title_sm_leave_reject'),
+            ];
+        else if($oConnection->isConnected($iInitiatorProfileId, $iContentProfileId, true))
+            $aResult = [
+                'add' => '',
+                'remove' => _t(!empty($CNF['T']['menu_item_title_sm_leave']) ? $CNF['T']['menu_item_title_sm_leave'] : '_sys_menu_item_title_sm_leave'),
+            ];
+        else
+            $aResult = [
+                'add' => _t(!empty($CNF['T']['menu_item_title_sm_join']) ? $CNF['T']['menu_item_title_sm_join'] : '_sys_menu_item_title_sm_join'),
+                'remove' => '',
+            ];
+
+        return !empty($sAction) && isset($aResult[$sAction]) ? $aResult[$sAction] : $aResult;
+    }
+
+    protected function _getImagesForTimelinePost($aEvent, $aContentInfo, $sUrl, $aBrowseParams = array())
+    {
+        $CNF = &$this->_oConfig->CNF;
+        
+        $iImageId = 0;
+        $sImageMd = $sImageXl = '';
+        if(isset($CNF['FIELD_COVER']) && !empty($aContentInfo[$CNF['FIELD_COVER']])) {
+            $iImageId = (int)$aContentInfo[$CNF['FIELD_COVER']];
+            $sImageMd = $this->_oConfig->getImageUrl($iImageId, ['OBJECT_IMAGES_TRANSCODER_GALLERY']);
+            $sImageXl = $this->_oConfig->getImageUrl($iImageId, ['OBJECT_IMAGES_TRANSCODER_COVER']);
+        }
+
+        if($sImageXl == '' && isset($CNF['FIELD_PICTURE']) && !empty($aContentInfo[$CNF['FIELD_PICTURE']])) {
+            $iImageId = (int)$aContentInfo[$CNF['FIELD_PICTURE']];
+            $sImageMd = $this->_oConfig->getImageUrl($iImageId, ['OBJECT_IMAGES_TRANSCODER_GALLERY']);
+            $sImageXl = $this->_oConfig->getImageUrl($iImageId, ['OBJECT_IMAGES_TRANSCODER_COVER']);
+        }
+
+        if(empty($sImageXl))
+            return [];
+
+        $aImage = [
+            'id' => $iImageId, 
+            'url' => $sUrl, 
+            'src' => $sImageXl, 
+            'src_orig' => $sImageXl
+        ];
+
+        if(!empty($sImageMd))
+            $aImage['src_medium'] = $sImageMd;
+
+        return [
+            $aImage
+        ];
+    }
+
+    protected function _prepareProfileAndGroupProfile($iGroupProfileId, $iInitiatorId)
+    {
+        if (!($oGroupProfile = BxDolProfile::getInstance($iGroupProfileId)))
+            return array(0, 0, null);
+
+        if ($oGroupProfile->getModule() == $this->getName()) {
+            $iProfileId = $iInitiatorId;
+            $iGroupProfileId = $oGroupProfile->id();
+        } else {
+            $iProfileId = $oGroupProfile->id();
+            $iGroupProfileId = $iInitiatorId;
+        }
+
+        return array($iProfileId, $iGroupProfileId, $oGroupProfile);
+    }
+}
+
+/** @} */

+ 286 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/modules/base/profile/classes/BxBaseModProfileFormEntry.php

@@ -0,0 +1,286 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    BaseProfile Base classes for profile modules
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Create/edit profile form.
+ */
+class BxBaseModProfileFormEntry extends BxBaseModGeneralFormEntry
+{
+    protected $_iAccountProfileId = 0;
+    protected $_aImageFields = array ();
+    
+    protected $_aUploadersInfo = [];
+
+    public function __construct($aInfo, $oTemplate = false)
+    {   
+        if (!isset($this->_bAllowChangeUserForAdmins))
+            $this->_bAllowChangeUserForAdmins = false;
+        
+        parent::__construct($aInfo, $oTemplate);
+
+        $this->_sAuthorKey = 'profile_id';
+
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        if (!empty($CNF['FIELD_PICTURE']) && isset($this->aInputs[$CNF['FIELD_PICTURE']])) {
+            $this->_aImageFields[$CNF['FIELD_PICTURE']] = array (
+                'storage_object' => $CNF['OBJECT_STORAGE'],
+                'images_transcoder' => $CNF['OBJECT_IMAGES_TRANSCODER_THUMB'],
+                'uploaders' => $CNF['OBJECT_UPLOADERS_PICTURE'],
+            );
+        }
+
+        if (!empty($CNF['FIELD_COVER']) && isset($this->aInputs[$CNF['FIELD_COVER']])) {
+            $sStorage = $this->_oModule->_oConfig->getObject($CNF['OBJECT_STORAGE_COVER']);
+            $sUploadersId = genRndPwd(8, false);
+            $aUploaders = !empty($this->aInputs[$CNF['FIELD_COVER']]['value']) ? unserialize($this->aInputs[$CNF['FIELD_COVER']]['value']) : $this->_oModule->_oConfig->getUploaders($CNF['FIELD_COVER']);
+
+            foreach($aUploaders as $sUploader){
+                $this->_aUploadersInfo[$sUploader] = array(
+                    'id' => $sUploadersId, 
+                    'name' => $sUploader,
+                    'js_object' => BxDolUploader::getObjectInstance($sUploader, $sStorage, $sUploadersId)->getNameJsInstanceUploader()
+                );
+            }
+            
+            $this->_aImageFields[$CNF['FIELD_COVER']] = array (
+                'storage_object' => $CNF['OBJECT_STORAGE_COVER'],
+                'images_transcoder' => $CNF['OBJECT_IMAGES_TRANSCODER_COVER_THUMB'],
+                'uploaders_id' => $sUploadersId,
+                'uploaders' => $aUploaders
+            );
+        }
+
+        foreach ($this->_aImageFields as $sField => $aParams) {
+            $this->aInputs[$sField]['storage_object'] = $aParams['storage_object'];
+            $this->aInputs[$sField]['uploaders'] = !empty($this->aInputs[$sField]['value']) ? unserialize($this->aInputs[$sField]['value']) : $aParams['uploaders'];
+            $this->aInputs[$sField]['images_transcoder'] = $aParams['images_transcoder'];
+            $this->aInputs[$sField]['uploaders_id'] = isset($aParams['uploaders_id']) ? $aParams['uploaders_id'] : '';
+            $this->aInputs[$sField]['storage_private'] = 0;
+            $this->aInputs[$sField]['multiple'] = false;
+            $this->aInputs[$sField]['content_id'] = 0;
+            $this->aInputs[$sField]['ghost_template'] = '';
+        }
+
+        $oAccountProfile = BxDolProfile::getInstanceAccountProfile();
+        if ($oAccountProfile)
+            $this->_iAccountProfileId = $oAccountProfile->id();
+    }
+
+    public function getUploadersInfo($sField = '')
+    {
+        if(empty($sField))
+            return $this->_aUploadersInfo;
+
+        $aUploaders = !empty($this->aInputs[$sField]['value']) ? unserialize($this->aInputs[$sField]['value']) : $this->_oModule->_oConfig->getUploaders($sField);
+
+        return $this->_aUploadersInfo[array_shift($aUploaders)];
+    }
+    
+    function initChecker ($aValues = array (), $aSpecificValues = array())
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $aContentInfo = isset($CNF['FIELD_ID']) && isset($aValues[$CNF['FIELD_ID']]) ? $this->_oModule->_oDb->getContentInfoById ($aValues[$CNF['FIELD_ID']]) : array();
+        
+        foreach ($this->_aImageFields as $sField => $aParams) {
+
+            if ($aValues && !empty($aValues[$CNF['FIELD_ID']]))
+                $this->aInputs[$sField]['content_id'] = $aValues[$CNF['FIELD_ID']];
+
+            $this->aInputs[$sField]['ghost_template'] = $this->_oModule->_oTemplate->parseHtmlByName('form_ghost_template.html', $this->_getProfilePhotoGhostTmplVars($sField, $aContentInfo));
+        }
+
+        parent::initChecker($aValues, $aSpecificValues);
+
+        if(($sField = 'FIELD_STG_TABS') && !empty($CNF[$sField]) && !empty($this->aInputs[$CNF[$sField]]) && is_array($this->aInputs[$CNF[$sField]]) && ($sValue = $this->aInputs[$CNF[$sField]]['value']))
+            $this->aInputs[$CNF[$sField]]['value'] = !is_array($sValue) ? explode(',', $sValue) : [];
+    }
+
+    function update ($iContentId, $aValsToAdd = array(), &$aTrackTextFieldsChanges = null)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        if(($sField = 'FIELD_STG_TABS') && !empty($CNF[$sField]) && empty($aValsToAdd[$CNF[$sField]])) {
+            $mixedValue = $this->getCleanValue($CNF[$sField]);
+            if(is_array($mixedValue))
+                self::setSubmittedValue($CNF[$sField], implode(',', $mixedValue), $this->aFormAttrs['method']);
+        }
+
+        return parent::update($iContentId, $aValsToAdd, $aTrackTextFieldsChanges);
+    }
+
+    function delete ($iContentId, $aContentInfo = array())
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $iProfileId = $this->getContentOwnerProfileId($iContentId);        
+
+        foreach ($this->_aImageFields as $sField => $aParams) {
+            $oStorage = BxDolStorage::getObjectInstance($aParams['storage_object']);
+            $aFiles = $oStorage->getGhosts($iProfileId, $iContentId);
+
+            foreach ($aFiles as $aFile) {
+                if (!$oStorage->getFile($aFile['id']))
+                    continue;
+                $bRet = $oStorage->deleteFile($aFile['id'], $this->_iAccountProfileId);
+            }
+        }
+
+        return parent::delete($iContentId, $aContentInfo);
+    }
+
+    protected function genCustomViewRowValueProfileEmail($aInput)
+    {
+        return $this->genCustomViewRowValueProfileEmailOrIp($aInput);
+    }
+    
+    protected function genCustomViewRowValueProfileIp($aInput)
+    {
+        return $this->genCustomViewRowValueProfileEmailOrIp($aInput);
+    }
+    
+    protected function genCustomViewRowValueFriendsCount($aInput)
+    {
+        if (isset($this->_oModule->_oConfig->CNF['URI_VIEW_FRIENDS'])){
+            $oProfile = $this->_oModule->getProfileByCurrentUrl();
+            if ($oProfile){
+                $oConnection = BxDolConnection::getObjectInstance('sys_profiles_friends');
+                $iCount = $oConnection->getConnectedContentCount($oProfile->id(), true);
+                return $this->_oModule->_oTemplate->parseHtmlByName('name_link.html', array(
+                    'href' => bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $this->_oModule->_oConfig->CNF['URI_VIEW_FRIENDS'] . '&profile_id=' . $oProfile->id())),
+                    'title' => '',
+                    'content' => $iCount
+                ));
+            }
+        }
+        return '';
+    }
+    
+    protected function genCustomViewRowValueFollowersCount($aInput)
+    {
+        if (isset($this->_oModule->_oConfig->CNF['URI_VIEW_SUBSCRIPTIONS'])){
+            $oProfile = $this->_oModule->getProfileByCurrentUrl();
+            if ($oProfile){
+                $oConnectionFollow = BxDolConnection::getObjectInstance('sys_profiles_subscriptions');
+                $iCount = $oConnectionFollow->getConnectedInitiatorsCount($oProfile->id());
+                return $this->_oModule->_oTemplate->parseHtmlByName('name_link.html', array(
+                    'href' => bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $this->_oModule->_oConfig->CNF['URI_VIEW_SUBSCRIPTIONS'] . '&profile_id=' . $oProfile->id())),
+                    'title' => '',
+                    'content' => $iCount
+                ));
+            }
+        }
+        return '';
+    }
+    
+    private function genCustomViewRowValueProfileEmailOrIp($aInput)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+        if(empty($aInput['value']))
+            return '';
+
+        $sValue = $aInput['value'];
+
+        $sModuleAccounts = 'bx_accounts';
+    	if(!BxDolModuleQuery::getInstance()->isEnabledByName($sModuleAccounts))
+    		return $sValue;
+
+		$oModuleAccounts = BxDolModule::getInstance($sModuleAccounts);
+		if(!$oModuleAccounts || empty($oModuleAccounts->_oConfig->CNF['URL_MANAGE_ADMINISTRATION']))
+			return $sValue;
+
+        return $this->_oModule->_oTemplate->parseHtmlByName('name_link.html', array(
+            'href' => bx_absolute_url(BxDolPermalinks::getInstance()->permalink($oModuleAccounts->_oConfig->CNF['URL_MANAGE_ADMINISTRATION'], array(
+            	'filter' => urlencode($sValue)
+            ))),
+            'title' => '',
+            'content' => $sValue
+        ));
+    }
+    
+    protected function genCustomViewRowValueProfileStatus($aInput)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+        if(empty($aInput['value']))
+            return '';
+
+        $sStatus = _t('_sys_profile_status_' . $aInput['value']);
+        if(empty($CNF['URL_MANAGE_ADMINISTRATION']) || empty($CNF['FIELD_TITLE']) || empty($this->aInputs[$CNF['FIELD_TITLE']]['value']))
+            return $sStatus;
+
+        return $this->_oModule->_oTemplate->parseHtmlByName('name_link.html', array(
+            'href' => bx_absolute_url(BxDolPermalinks::getInstance()->permalink($CNF['URL_MANAGE_ADMINISTRATION'], array(
+            	'filter' => urlencode($this->aInputs[$CNF['FIELD_TITLE']]['value'])
+            ))),
+            'title' => '',
+            'content' => $sStatus
+        ));
+    }
+
+    protected function genCustomViewRowValueProfileLastActive($aInput)
+    {
+        $iLastActiveSession = 0;
+        if(!empty($this->_iContentId) && ($oProfile = BxDolProfile::getInstanceByContentAndType($this->_iContentId, $this->MODULE)) !== false)
+            $iLastActiveSession = (new BxDolSessionQuery())->getLastActivityAccount($oProfile->getAccountId());
+
+        $iLastActive = max($iLastActiveSession, (int)$aInput['value']);
+        return !empty($iLastActive) ? bx_time_js($iLastActive) : '';
+    }
+
+    protected function _associalFileWithContent($oStorage, $iFileId, $iProfileId, $iContentId, $sPictureField = '')
+    {
+        $oStorage->updateGhostsContentId ($iFileId, $iProfileId, $iContentId, $this->_isAdmin($iContentId));
+
+        $bResult = (int)$this->_oModule->_oDb->updateContentPictureById($iContentId, 0/*$iProfileId*/, $iFileId, $sPictureField) > 0;
+        if(!$bResult) 
+            return;
+
+        $this->_oModule->onUpdateImage($iContentId, $sPictureField, $iFileId, $iProfileId);
+    }
+
+    protected function _getProfilePhotoGhostTmplVars($sField, $aContentInfo = array())
+    {
+    	$CNF = &$this->_oModule->_oConfig->CNF;
+
+    	return [
+            'name' => $this->aInputs[$sField]['name'],
+            'content_id' => $this->aInputs[$sField]['content_id'],
+            'bx_if:set_thumb' => [
+                'condition' => false,
+                'content' => [],
+            ]
+        ];
+    }
+
+    protected function _isAdmin ($iContentId = 0)
+    {
+        if (parent::_isAdmin ($iContentId))
+            return true;
+        if (!$iContentId || !($aDataEntry = $this->_oModule->_oDb->getContentInfoById((int)$iContentId)))
+            return false;
+        return CHECK_ACTION_RESULT_ALLOWED == $this->_oModule->checkAllowedEdit ($aDataEntry);        
+    }
+
+    protected function _getPrivacyFields($aKeysF2O = array())
+    {
+        if(empty($aKeysF2O))
+            $aKeysF2O = array(
+                'FIELD_ALLOW_VIEW_TO' => 'OBJECT_PRIVACY_VIEW',
+                'FIELD_ALLOW_POST_TO' => 'OBJECT_PRIVACY_POST',
+                'FIELD_ALLOW_CONTACT_TO' => 'OBJECT_PRIVACY_CONTACT'
+            );
+
+        return parent::_getPrivacyFields($aKeysF2O);
+    }
+}
+
+/** @} */

+ 282 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/modules/base/profile/classes/BxBaseModProfileFormsEntryHelper.php

@@ -0,0 +1,282 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    BaseProfile Base classes for profile modules
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+/**
+ * Profile forms helper functions
+ */
+class BxBaseModProfileFormsEntryHelper extends BxBaseModGeneralFormsEntryHelper
+{
+    protected $_sAutoApproval = false;
+
+    public function __construct($oModule)
+    {
+        parent::__construct($oModule);
+
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $sAutoApproval = isset($CNF['PARAM_AUTOAPPROVAL']) ? getParam($CNF['PARAM_AUTOAPPROVAL']) : BX_DOL_PROFILE_ACTIVATE_ALWAYS;
+        $bAdministrator = BxDolAcl::getInstance()->isMemberLevelInSet(array(MEMBERSHIP_ID_ADMINISTRATOR));
+        if (isAdmin() || $bAdministrator)
+            $sAutoApproval = BX_DOL_PROFILE_ACTIVATE_ALWAYS;
+        $this->setAutoApproval($sAutoApproval);
+    }
+
+    public function isAutoApproval($sAction = BX_DOL_PROFILE_ACTIVATE_ALWAYS)
+    {
+        if ($this->_sAutoApproval == BX_DOL_PROFILE_ACTIVATE_ALWAYS || $this->_sAutoApproval == $sAction)
+            return true;
+        
+        return false;
+    }
+    
+    public function setAutoApproval($mValue)
+    {
+        if ($mValue === true)
+            $mValue = BX_DOL_PROFILE_ACTIVATE_ALWAYS;
+        
+        return ($this->_sAutoApproval = $mValue);
+    }
+
+    public function getObjectFormEdit ($sDisplay = false)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $oForm = parent::getObjectFormEdit ($sDisplay);
+        if(!$oForm)
+            return $oForm;
+
+        switch($sDisplay) {
+            case $CNF['OBJECT_FORM_ENTRY_DISPLAY_EDIT_SETTINGS']:
+                //--- Fill in tabs list
+                if(($sField = 'FIELD_STG_TABS') && !empty($CNF[$sField]) && !empty($oForm->aInputs[$CNF[$sField]]) && is_array($oForm->aInputs[$CNF[$sField]])) 
+                    if(($sMenu = 'OBJECT_MENU_SUBMENU_VIEW_ENTRY') && !empty($CNF[$sMenu]) && ($oMenu = BxDolMenu::getObjectInstance($CNF[$sMenu])) !== false && ($aMenuItems = $oMenu->getQueryObject()->getMenuItems()))
+                        foreach($aMenuItems as $aMenuItem) {
+                            if($aMenuItem['name'] == 'more-auto' || (int)$aMenuItem['active'] == 0)
+                                continue;
+
+                            $oForm->aInputs[$CNF[$sField]]['values'][] = [
+                                'key' => $aMenuItem['name'],
+                                'value' => _t($aMenuItem['title'])
+                            ];
+                        }
+                break;
+        }
+
+        return $oForm;
+    }
+
+    protected function _getProfileAndContentData ($iContentId)
+    {
+        $aContentInfo = $this->_oModule->_oDb->getContentInfoById($iContentId);
+        if(!$aContentInfo)
+            return array (false, false);
+
+        $oProfile = BxDolProfile::getInstanceMagic($aContentInfo['profile_id']);
+        return array($oProfile, $aContentInfo);
+    }
+
+    public function deleteData ($iContentId, $aContentInfo = false, $oProfile = null, $oForm = null)
+    {
+        if (!$aContentInfo)
+            list ($oProfile, $aContentInfo) = $this->_getProfileAndContentData($iContentId);
+
+        // delete profile with content
+        $oProfile = BxDolProfile::getInstance($aContentInfo['profile_id']);
+        if (!$oProfile->delete(false, true))
+            return _t('_sys_txt_error_entry_delete');
+
+        return '';
+    }
+
+    public function deleteDataService ($iContentId, $aContentInfo = false, $oProfile = null, $oForm = null)
+    {
+        return parent::deleteData ($iContentId, $aContentInfo, $oProfile, $oForm);
+    }
+
+    public function onDataDeleteAfter ($iContentId, $aContentInfo, $oProfile)
+    {
+        if($s = parent::onDataDeleteAfter($iContentId, $aContentInfo, $oProfile))
+            return $s;
+
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $bActAsProfile = BxDolService::call($oProfile->getModule(), 'act_as_profile');
+        if(($oPrivacyView = BxDolPrivacy::getObjectInstance($CNF['OBJECT_PRIVACY_VIEW'])) !== false && $bActAsProfile)
+            $oPrivacyView->deleteGroupCustomByProfileId($oProfile->id());
+
+        if(($oPrivacyPost = BxDolPrivacy::getObjectInstance($CNF['OBJECT_PRIVACY_POST'])) !== false) {
+            $oPrivacyPost->deleteGroupCustomByContentId($iContentId);
+
+            if($bActAsProfile)
+                $oPrivacyPost->deleteGroupCustomByProfileId($oProfile->id());
+        }
+
+        return '';
+    }
+
+    public function onDataEditBefore ($iContentId, $aContentInfo, &$aTrackTextFieldsChanges, &$oProfile, &$oForm)
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        list ($oProfile, $aContentInfo) = $this->_getProfileAndContentData($iContentId);
+
+        $oEditedProfile = BxDolProfile::getInstanceMagic($aContentInfo['profile_id']);
+        
+        $sStatus = $oEditedProfile->getStatus();
+        if (!$this->isAutoApproval(BX_DOL_PROFILE_ACTIVATE_EDIT) && BX_PROFILE_STATUS_ACTIVE == $sStatus){
+            $aTrackTextFieldsChanges = array ();
+        }
+
+        if(isset($CNF['FIELD_BIRTHDAY']) && isset($aContentInfo[$CNF['FIELD_BIRTHDAY']]))
+            $oForm->addTrackFields($CNF['FIELD_BIRTHDAY'], $aContentInfo);
+    }
+
+    public function onDataEditAfter ($iContentId, $aContentInfo, $aTrackTextFieldsChanges, $oProfile, $oForm)
+    {
+        if ($s = parent::onDataEditAfter($iContentId, $aContentInfo, $aTrackTextFieldsChanges, $oProfile, $oForm)){
+            return $s;
+        }
+
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        /*
+         * Load updated data.
+         */
+        $aContentInfo = $this->_oModule->_oDb->getContentInfoById($iContentId);
+       
+        // change profile to 'pending' only if profile is 'active' 
+        $oEditedProfile = BxDolProfile::getInstanceMagic($aContentInfo['profile_id']);
+        $sStatus = $oEditedProfile->getStatus();
+
+        if (!$this->isAutoApproval(BX_DOL_PROFILE_ACTIVATE_EDIT) && BX_PROFILE_STATUS_ACTIVE == $sStatus && !empty($aTrackTextFieldsChanges['changed_fields']))
+            $oEditedProfile->disapprove(BX_PROFILE_ACTION_AUTO, 0, $this->_oModule->serviceActAsProfile());
+
+        // process uploaded files
+        if (isset($CNF['FIELD_PICTURE']))
+            $oForm->processFiles($CNF['FIELD_PICTURE'], $iContentId, false);
+        if (isset($CNF['FIELD_COVER']))
+            $oForm->processFiles($CNF['FIELD_COVER'], $iContentId, false);
+
+        if(isset($CNF['FIELD_ALLOW_POST_TO']) && !empty($aContentInfo[$CNF['FIELD_ALLOW_POST_TO']]) && ($oPrivacy = BxDolPrivacy::getObjectInstance($CNF['OBJECT_PRIVACY_POST'])) !== false)
+            $oPrivacy->reassociateGroupCustomWithContent($oProfile->id(), $iContentId, (int)$aContentInfo[$CNF['FIELD_ALLOW_POST_TO']]);
+
+        // update content filters
+        if(isset($CNF['FIELD_BIRTHDAY']) && $oForm->isTrackFieldChanged($CNF['FIELD_BIRTHDAY']))
+            BxDolContentFilter::getInstance()->updateValuesByProfile($oProfile->getInfo());
+
+        return '';
+    }
+
+    public function onDataAddAfter ($iAccountId, $iContentId)
+    {
+        /*
+         * Add account and content association.
+         * Note. It should be done first to correctly get and use author's profile later.
+         */
+        $iProfileId = BxDolProfile::add(BX_PROFILE_ACTION_MANUAL, $iAccountId, $iContentId, BX_PROFILE_STATUS_PENDING, $this->_oModule->getName());
+        $oProfile = BxDolProfile::getInstance($iProfileId);
+
+        if($s = parent::onDataAddAfter($iAccountId, $iContentId))
+            return $s;
+
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        /*
+         * Load updated data.
+         */
+        $aContentInfo = $this->_oModule->_oDb->getContentInfoById($iContentId);
+
+        // approve profile if auto-approval is enabled and profile status is 'pending'
+        $sStatus = $oProfile->getStatus();
+        if ($sStatus == BX_PROFILE_STATUS_PENDING && $this->isAutoApproval(BX_DOL_PROFILE_ACTIVATE_ADD))
+            $oProfile->approve(BX_PROFILE_ACTION_AUTO, 0, $this->_oModule->serviceActAsProfile() && $this->_oModule->serviceIsEnableProfileActivationLetter());
+
+        // set created profile some default membership
+        if ((int)bx_get('level_id') && bx_srv('bx_acl', 'get_prices', [(int)bx_get('level_id'), true]))
+            $iAclLevel = (int)bx_get('level_id');
+        else if(isset($CNF['PARAM_DEFAULT_ACL_LEVEL']))
+            $iAclLevel = isAdmin() && getLoggedId() == $iAccountId ? MEMBERSHIP_ID_ADMINISTRATOR : getParam($CNF['PARAM_DEFAULT_ACL_LEVEL']);
+        else
+            $iAclLevel = MEMBERSHIP_ID_STANDARD;
+
+        BxDolAcl::getInstance()->setMembership($iProfileId, $iAclLevel, 0, true);
+        
+        // process uploaded files
+        $oForm = $this->getObjectFormAdd();
+        if (isset($CNF['FIELD_PICTURE']))
+            $oForm->processFiles($CNF['FIELD_PICTURE'], $iContentId, true);
+        if (isset($CNF['FIELD_COVER']))
+            $oForm->processFiles($CNF['FIELD_COVER'], $iContentId, true);
+
+        if(isset($CNF['FIELD_ALLOW_POST_TO']) && !empty($aContentInfo[$CNF['FIELD_ALLOW_POST_TO']]) && ($oPrivacy = BxDolPrivacy::getObjectInstance($CNF['OBJECT_PRIVACY_POST'])) !== false)
+            $oPrivacy->associateGroupCustomWithContent($oProfile->id(), $iContentId, (int)$aContentInfo[$CNF['FIELD_ALLOW_POST_TO']]);
+
+        // switch context to the created profile
+        if ($this->_oModule->serviceActAsProfile()) {
+            $oAccount = BxDolAccount::getInstance($iAccountId);
+            $oAccount->updateProfileContext($iProfileId);
+
+            //update recomedations
+            BxDolRecommendation::updateData($iProfileId);
+
+            // update content filters
+            BxDolContentFilter::getInstance()->updateValuesByProfile($oProfile->getInfo());
+        }
+
+        return '';
+    }
+
+    public function redirectAfterAdd($aContentInfo, $sUrl = '')
+    {
+        $CNF = &$this->_oModule->_oConfig->CNF;
+
+        $oSession = BxDolSession::getInstance();
+
+        $sRedirectType = empty($CNF['PARAM_REDIRECT_AADD']) ? BX_DOL_PROFILE_REDIRECT_PROFILE : getParam($CNF['PARAM_REDIRECT_AADD']);
+        $sRedirectDefault = 'page.php?i=' . $CNF['URI_VIEW_ENTRY'] . '&id=' . $aContentInfo[$CNF['FIELD_ID']];
+
+        $sRedirectUrl = $sRedirectDefault;
+        switch ($sRedirectType) {
+            case BX_DOL_PROFILE_REDIRECT_PROFILE:
+                break;
+
+            // if user just joined the redirect to the page where user comes from. 
+            case BX_DOL_PROFILE_REDIRECT_LAST:
+                $sJoinReferrer = $oSession->getValue('join-referrer');
+                if($sJoinReferrer) {
+                    $sRedirectUrl = $sJoinReferrer;
+                    $oSession->unsetValue('join-referrer');
+                }
+                break;
+
+            case BX_DOL_PROFILE_REDIRECT_CUSTOM:
+                $sRedirectCustom = getParam($CNF['PARAM_REDIRECT_AADD_CUSTOM_URL']);
+                if($sRedirectCustom) {
+                    $sRedirectUrl = $this->prepareCustomRedirectUrl($sRedirectCustom, $aContentInfo);
+                }
+                break;
+                
+            case BX_DOL_PROFILE_REDIRECT_HOMEPAGE:
+                $sRedirectUrl =  BX_DOL_URL_ROOT;  
+                break;
+        }
+
+        $sCustomReferrer = $oSession->getValue('custom-referrer');
+        if($sCustomReferrer) {
+            $sRedirectUrl = $sCustomReferrer;
+            $oSession->unsetValue('custom-referrer');
+        }
+
+        return parent::redirectAfterAdd($aContentInfo, $sRedirectUrl);
+    }
+}
+
+/** @} */

+ 2556 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/modules/base/profile/classes/BxBaseModProfileModule.php

@@ -0,0 +1,2556 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    BaseProfile Base classes for profile modules
+ * @ingroup     UnaModules
+ *
+ * @{
+ */
+
+define('BX_DOL_PROFILE_REDIRECT_PROFILE', 'profile');
+define('BX_DOL_PROFILE_REDIRECT_LAST', 'last');
+define('BX_DOL_PROFILE_REDIRECT_CUSTOM', 'custom');
+define('BX_DOL_PROFILE_REDIRECT_HOMEPAGE', 'homepage');
+
+define('BX_DOL_PROFILE_ACTIVATE_ALWAYS', 'on');
+define('BX_DOL_PROFILE_ACTIVATE_NEVER', 'off');
+define('BX_DOL_PROFILE_ACTIVATE_ADD', 'add');
+define('BX_DOL_PROFILE_ACTIVATE_EDIT', 'edit');
+
+/**
+ * Base class for profile modules.
+ */
+class BxBaseModProfileModule extends BxBaseModGeneralModule implements iBxDolContentInfoService, iBxDolProfileService
+{
+    protected $_iAccountId;
+
+    function __construct(&$aModule)
+    {
+        parent::__construct($aModule);
+
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!empty($CNF['FIELD_ALLOW_POST_TO']))
+            $this->_aSearchableNamesExcept[] = $CNF['FIELD_ALLOW_POST_TO'];
+
+        if(!empty($CNF['FIELD_ALLOW_CONTACT_TO']))
+            $this->_aSearchableNamesExcept[] = $CNF['FIELD_ALLOW_CONTACT_TO'];
+
+        $this->_iAccountId = getLoggedId();
+    }
+
+    public function actionDeleteProfileImg($iFileId, $iContentId, $sFieldPicture) 
+    {
+        $aResult = array();
+        $CNF = &$this->_oConfig->CNF;
+
+        $oSrorage = BxDolStorage::getObjectInstance($CNF['OBJECT_STORAGE']);
+        if (!($aFile = $oSrorage->getFile((int)$iFileId)) || !($aContentInfo = $this->_oDb->getContentInfoById($iContentId)) || $aContentInfo[$sFieldPicture] != (int)$iFileId)
+            $aResult = array('error' => 1, 'msg' => _t('_sys_storage_err_file_not_found'));
+
+        $oAccountProfile = BxDolProfile::getInstanceAccountProfile();
+        if ($oAccountProfile)
+            $iAccountProfileId = $oAccountProfile->id();
+
+        if ((!$aResult && !isLogged()) || (!$aResult && $aFile['profile_id'] != $iAccountProfileId && !$this->_isModerator()))           
+            $aResult = array('error' => 2, 'msg' => _t('_Access denied'));
+
+        $oForm = BxDolForm::getObjectInstance($CNF['OBJECT_FORM_ENTRY'], $CNF['OBJECT_FORM_ENTRY_DISPLAY_ADD'], $this->_oTemplate);
+
+        if (!$aResult && !$oForm->_deleteFile($iContentId, $sFieldPicture, (int)$iFileId, true))
+            $aResult = array('error' => 3, 'msg' => _t('_Failed'));
+        elseif (!$aResult)            
+            $aResult = array('error' => 0, 'msg' => '');
+
+        header('Content-Type: application/json; charset=utf-8');
+        echo json_encode($aResult);
+    }
+	
+	public function actionProfileCalendarData()
+    {
+		$iProfileId = (int)bx_get('profile_id');
+        $aEntries = array();
+		$BxDolModuleQuery = BxDolModuleQuery::getInstance();
+        $aModules = $BxDolModuleQuery->getModulesBy(array('type' => 'modules', 'active' => 1));
+        foreach($aModules as $aModule){
+			$sModuleName = $aModule['name'];
+            $oModule = BxDolModule::getInstance($sModuleName);
+            if($oModule instanceof iBxDolCalendarService){
+				if(BxDolRequest::serviceExists($sModuleName, 'get_calendar_entries')){
+					$aEntries2 = BxDolService::call($sModuleName, 'get_calendar_entries', array($iProfileId));
+					$aEntries = array_merge($aEntries, $aEntries2);
+				}
+            }
+        }
+		
+        header('Content-Type: application/json; charset=utf-8');
+        echo json_encode($aEntries);
+    }
+    
+    public function actionEmbed($iContentId, $sUnitTemplate = '', $sAddCode = '')
+    {
+        return parent::actionEmbed($iContentId, 'unit_with_cover.html');
+    }
+
+    // ====== SERVICE METHODS
+
+    public function serviceGetSafeServices()
+    {
+        $a = parent::serviceGetSafeServices();
+        return array_merge($a, array (
+            'ProfileUnitSafe' => '',
+            'ProfileUrl' => '',
+            'EntityCreate' => '',
+            'BrowseRecommended' => '',
+            'BrowseRecentProfiles' => '',
+            'BrowseActiveProfiles' => '',
+            'BrowseTopProfiles' => '',
+            'BrowseOnlineProfiles' => '',
+            'BrowseConnections' => '',
+            'BrowseConnectionsEverywhere' => '',
+            'BrowseByAcl' => '',
+        ));
+    }
+
+    public function serviceGetOptionsRedirectAfterAdd()
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $aResult = array();
+        $aChoices = array(BX_DOL_PROFILE_REDIRECT_PROFILE, BX_DOL_PROFILE_REDIRECT_LAST, BX_DOL_PROFILE_REDIRECT_CUSTOM, BX_DOL_PROFILE_REDIRECT_HOMEPAGE);
+        foreach($aChoices as $sChoice) 
+            $aResult[] = array('key' => $sChoice, 'value' => _t($CNF['T']['option_redirect_aadd_' . $sChoice]));
+
+        return $aResult;
+    }
+    
+    public function serviceGetOptionsActivation()
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $aResult = array();
+        $aChoices = array(BX_DOL_PROFILE_ACTIVATE_ALWAYS, BX_DOL_PROFILE_ACTIVATE_NEVER, BX_DOL_PROFILE_ACTIVATE_ADD, BX_DOL_PROFILE_ACTIVATE_EDIT);
+        foreach($aChoices as $sChoice) 
+            $aResult[] = array('key' => $sChoice, 'value' => _t($CNF['T']['option_activation_' . $sChoice]));
+
+        return $aResult;
+    }
+
+    public function serviceGetThumb ($iContentId, $sTranscoder = '') 
+    {
+        $CNF = &$this->_oConfig->CNF;
+        
+        if(bx_is_api()){
+            $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+            return bx_api_get_image($CNF['OBJECT_STORAGE'], $aContentInfo[$CNF['FIELD_PICTURE']]);
+        }
+
+        if(empty($sTranscoder) && !empty($CNF['OBJECT_IMAGES_TRANSCODER_THUMB']))
+            $sTranscoder = $CNF['OBJECT_IMAGES_TRANSCODER_THUMB'];
+
+        $mixedResult = $this->_getFieldValueThumb('FIELD_PICTURE', $iContentId, $sTranscoder);
+        return $mixedResult !== false ? $mixedResult : '';
+    }
+    
+    public function serviceGetCover ($iContentId, $sTranscoder = '') 
+    {
+        $CNF = &$this->_oConfig->CNF;
+        
+        if(bx_is_api()){
+            $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+            $aTmp = bx_api_get_image($CNF['OBJECT_STORAGE'], $aContentInfo[$CNF['FIELD_COVER']]);
+            if (!$aTmp){
+                return ['storage' => $CNF['OBJECT_STORAGE']];
+            }
+            return $aTmp;
+        }
+
+        if(empty($sTranscoder) && !empty($CNF['OBJECT_IMAGES_TRANSCODER_COVER']))
+            $sTranscoder = $CNF['OBJECT_IMAGES_TRANSCODER_COVER'];
+
+        $mixedResult = $this->_getFieldValueThumb('FIELD_COVER', $iContentId, $sTranscoder);
+        return $mixedResult !== false ? $mixedResult : '';
+    }
+
+    public function serviceGetSearchResultUnit ($iContentId, $sUnitTemplate = '')
+    {
+        if(empty($sUnitTemplate))
+            $sUnitTemplate = 'unit_with_cover.html';
+
+        return parent::serviceGetSearchResultUnit($iContentId, $sUnitTemplate);
+    }
+
+    public function serviceGetSearchableFieldsExtended($aInputsAdd = array())
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $aResult = parent::serviceGetSearchableFieldsExtended($aInputsAdd);
+        if(!$this->serviceActAsProfile())
+            return $aResult;
+
+        if(!in_array('online', $this->_aSearchableNamesExcept))
+            $aResult['online'] = array(
+                'type' => 'checkbox', 
+                'caption' => $CNF['T']['form_field_online'],
+                'info' => '',
+            	'value' => '1',
+                'values' => '',
+                'pass' => ''
+            );
+
+        if(!empty($CNF['FIELD_PICTURE']) && !in_array($CNF['FIELD_PICTURE'], $this->_aSearchableNamesExcept))
+            $aResult[$CNF['FIELD_PICTURE']] = array(
+                'type' => 'checkbox', 
+                'caption' => $CNF['T']['form_field_picture'],
+                'info' => '',
+            	'value' => '1',
+                'values' => '',
+                'pass' => '',
+                'search_operator' => '>=' 
+            );
+
+        return $aResult;
+    }
+
+	
+	/**
+     * @page service Service Calls
+     * @section bx_base_profile Base Profile
+     * @subsection bx_base_profile-general General
+     * @subsubsection bx_base_profile-entity_calendar entity_calendar
+     * 
+     * @code bx_srv('bx_persons', 'entity_calendar', [...]); @endcode
+     * 
+     * Get calendar block
+     * @param $iContentId content ID
+     * 
+     * @see BxBaseModProfileModule::serviceEntityCalendar
+     */
+    /** 
+     * @ref bx_base_profile-entity_calendar "entity_calendar"
+     */
+	public function serviceEntityCalendar($iContentId = 0, $sTemplate = 'calendar.html')
+    {
+		$iContentId = $this->_getContent($iContentId, false);
+        $oProfile = BxDolProfile::getInstanceByContentAndType($iContentId, $this->_oConfig->getName());
+        if($oProfile){
+			$o = new BxTemplCalendar(array(
+				'eventSources' => array (
+					bx_append_url_params(BX_DOL_URL_ROOT . $this->_oConfig->getBaseUri() . 'profile_calendar_data', array('profile_id' => $oProfile->id())),
+				),
+        	), $this->_oTemplate);
+        	return $o->display($sTemplate);			     
+		}
+		return '';	
+    }
+	
+    public function servicePrivateProfileMsg()
+    {
+        $sMessage = '';
+
+        $mixedContent = $this->_getContent();
+        if($mixedContent) {
+            list($iContentId, $aContentInfo) = $mixedContent;
+            $sMessage = $this->checkAllowedView($aContentInfo);
+        }
+        else
+            $sMessage = MsgBox(_t('_sys_access_denied_to_private_content'));
+
+        return !$this->_bIsApi ? $sMessage : [bx_api_get_msg($sMessage, ['ext' => ['msg_type' => 'result']])];
+    }
+    
+    public function serviceGetContentInfoById($iContentId)
+    {
+        return $this->_oDb->getContentInfoById((int)$iContentId);
+    }
+    
+    public function serviceGetContentInfoByProfileId($iProfileId)
+    {
+        return $this->_oDb->getContentInfoByProfileId((int)$iProfileId);
+    }
+
+    public function serviceGetMenuAddonManageTools()
+    {
+        $CNF = &$this->_oConfig->CNF;
+        
+		$iNumTotal = $this->_oDb->getEntriesNumByParams();
+
+        $iNum1 = $this->_oDb->getEntriesNumByParams([
+            [
+                'key' => 'status', 
+                'table' => 'sys_profiles',
+                'value' => BX_PROFILE_STATUS_PENDING, 
+                'operator' => '='
+            ]
+        ]);
+        
+        $iNum2 = 0;
+        if (isset($CNF['OBJECT_REPORTS'])){
+            $iNum2 = $this->_oDb->getEntriesNumByParams([
+                [
+                    'key' => 'reports',
+                    'value' => '0', 
+                    'operator' => '>'
+                ]
+            ]);
+        }
+        
+        return array('counter1_value' => $iNum1, 'counter2_value' => $iNum2, 'counter3_value' => $iNumTotal, 'counter1_caption' => _t('_sys_menu_dashboard_manage_tools_addon_counter1_caption_profile_default'));
+    }
+
+    public function serviceGetMenuAddonManageToolsProfileStats($iProfileId = 0)
+    {
+        if(!$iProfileId)
+            $iProfileId = bx_get_logged_profile_id();
+
+        $aAccountInfo = BxDolProfileQuery::getInstance()->getAccountInfoByProfileId($iProfileId);
+        if(empty($aAccountInfo) || !is_array($aAccountInfo))
+            return 0;
+
+        bx_import('SearchResult', $this->_aModule);
+        $sClass = $this->_aModule['class_prefix'] . 'SearchResult';
+        $o = new $sClass();
+        $o->fillFilters([
+            'account_id' => $aAccountInfo['id'],
+            'perofileStatus' => ''
+        ]);
+        $o->unsetPaginate();
+
+        return $o->getNum();
+    }
+
+    public function serviceGetMenuAddonFavoritesProfileStats()
+    {
+    	bx_import('SearchResult', $this->_aModule);
+        $sClass = $this->_aModule['class_prefix'] . 'SearchResult';
+        $o = new $sClass('favorite', array('user' => bx_get_logged_profile_id()));
+        $o->unsetPaginate();
+
+        return $o->getNum();
+    }
+
+    public function serviceGetSubmenuObject ()
+    {
+        return $this->_oConfig->CNF['OBJECT_MENU_SUBMENU_VIEW_ENTRY'];
+    }
+
+    public function serviceGetMenuSetNameForMenuTrigger ($sMenuTriggerName)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if (isset($CNF['TRIGGER_MENU_PROFILE_VIEW_SUBMENU']) && $CNF['TRIGGER_MENU_PROFILE_VIEW_SUBMENU'] == $sMenuTriggerName)
+            return $CNF['OBJECT_MENU_SUBMENU_VIEW_ENTRY'];
+        elseif (isset($CNF['TRIGGER_MENU_PROFILE_SNIPPET_META']) && $CNF['TRIGGER_MENU_PROFILE_SNIPPET_META'] == $sMenuTriggerName)
+            return $CNF['OBJECT_MENU_SNIPPET_META'];
+        else if (isset($CNF['TRIGGER_MENU_PROFILE_VIEW_ACTIONS']) && $CNF['TRIGGER_MENU_PROFILE_VIEW_ACTIONS'] == $sMenuTriggerName) {
+            if(empty($CNF['OBJECT_MENU_ACTIONS_VIEW_ENTRY_ALL']))
+                return $CNF['OBJECT_MENU_ACTIONS_VIEW_ENTRY'];
+            else
+                return array($CNF['OBJECT_MENU_ACTIONS_VIEW_ENTRY'], $CNF['OBJECT_MENU_ACTIONS_VIEW_ENTRY_ALL']);
+        }
+
+        return '';
+    }
+
+    public function serviceGetSnippetMenuVars($iProfileId, $bPublic = null, $aParams = [])
+    {
+        return $this->_oTemplate->getSnippetMenuVars($iProfileId, $bPublic, $aParams);
+    }
+
+    public function serviceGetPageObjectForPageTrigger ($sPageTriggerName)
+    {
+        if (isset($this->_oConfig->CNF['TRIGGER_PAGE_VIEW_ENTRY']) && $this->_oConfig->CNF['TRIGGER_PAGE_VIEW_ENTRY'] == $sPageTriggerName)
+        	return $this->_oConfig->CNF['OBJECT_PAGE_VIEW_ENTRY'];
+
+        return '';
+    }
+
+    public function serviceProfilesSearch ($sTerm, $iLimit)
+    {
+        $aRet = array();
+
+        $a = $this->_oDb->searchByTerm($sTerm, $iLimit);
+        foreach ($a as $r) {
+            $oProfile = BxDolProfile::getInstance($r['profile_id']);
+
+            if ($this->_bIsApi) {
+                $aData = $oProfile->getUnitAPI(0, ['template' => 'unit_wo_info']);
+                $aData['author_data']['label'] = $this->serviceProfileName($r['content_id']);
+
+                $aRet[] = $aData['author_data'];
+            }
+            else{
+                $aRet[] = array (
+                    'label' => $this->serviceProfileName($r['content_id']), 
+                    'value' => $r['profile_id'], 
+                    'url' => $oProfile->getUrl(),
+                    'thumb' => $oProfile->getThumb(),
+                    'unit' => $oProfile->getUnit(0, ['template' => ['name' => 'unit_wo_info', 'size' => 'icon']])
+                );
+            }
+        }
+
+        return $aRet;
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_profile Base Profile
+     * @subsection bx_base_profile-general General
+     * @subsubsection bx_base_profile-profile_unit_safe profile_unit_safe
+     * 
+     * @code bx_srv('bx_persons', 'profile_unit_safe', [...]); @endcode
+     * 
+     * Get profile unit
+     * @param $iContentId content ID
+     * 
+     * @see BxBaseModProfileModule::serviceProfileUnitSafe
+     */
+    /** 
+     * @ref bx_base_profile-profile_unit_safe "profile_unit_safe"
+     */
+    public function serviceProfileUnitSafe ($iContentId, $aParams = array())
+    {
+        unset($aParams['check_private_content']);
+        return $this->serviceProfileUnit ($iContentId, $aParams);
+    }
+
+    public function serviceProfileUnit ($iContentId, $aParams = [])
+    {
+        $mixedContent = $this->_getContent($iContentId);
+        if($mixedContent === false)
+            return false;
+
+        list($iContentId, $aContentInfo) = $mixedContent;
+
+        $bCheckPrivateContent = isset($aParams['check_private_content']) ? (bool)$aParams['check_private_content'] : true;
+
+        $aUnitTemplate = ['name' => 'unit.html', 'size' => false, 'vars' => []];
+        if(!empty($aParams['template'])) {
+            $mixedTemplate = $aParams['template'];
+            unset($aParams['template']);
+
+            if(is_string($mixedTemplate))
+                $aUnitTemplate['name'] = $mixedTemplate . '.html';
+            else if(is_array($mixedTemplate)) {
+                if(!empty($mixedTemplate['name']))
+                    $aUnitTemplate['name'] = $mixedTemplate['name'] . '.html';
+
+                if(!empty($mixedTemplate['size']))
+                    $aUnitTemplate['size'] = $mixedTemplate['size'];
+
+                if(!empty($mixedTemplate['vars']))
+                    $aUnitTemplate['vars'] = $mixedTemplate['vars'];
+            }
+        }
+
+        $aUnitParams = [];
+        if(!empty($aParams))
+            $aUnitParams = $aParams;
+
+        return $this->_oTemplate->unit($aContentInfo, $bCheckPrivateContent, array_values($aUnitTemplate), $aUnitParams);
+    }
+
+    public function serviceProfileUnitApi ($iContentId, $aParams = [])
+    {
+        $mixedContent = $this->_getContent($iContentId);
+        if($mixedContent === false)
+            return false;
+
+        list($iContentId, $aContentInfo) = $mixedContent;
+
+        return $this->_oTemplate->unitAPI($aContentInfo, $aParams);
+    }
+
+    public function serviceHasImage ($iContentId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $mixedContent = $this->_getContent($iContentId, 'getContentInfoById');
+        if($mixedContent === false)
+            return false;
+
+        list($iContentId, $aContentInfo) = $mixedContent;
+
+        return !empty($aContentInfo[$CNF['FIELD_PICTURE']]);
+    }
+
+    public function serviceProfilePicture ($iContentId)
+    {
+        return $this->_serviceTemplateFunc('urlPicture', $iContentId);
+    }
+
+    public function serviceProfileAvatar ($iContentId)
+    {
+        return $this->_serviceTemplateFunc('urlAvatar', $iContentId);
+    }
+
+    public function serviceProfileAvatarBig ($iContentId)
+    {
+        return $this->_serviceTemplateFunc('urlAvatarBig', $iContentId);
+    }
+
+    public function serviceProfileCover ($iContentId)
+    {
+        return $this->_serviceTemplateFunc('urlCover', $iContentId);
+    }
+
+    public function serviceProfileUnitCover ($iContentId)
+    {
+        return $this->_serviceTemplateFunc('urlCoverUnit', $iContentId);
+    }
+
+    public function serviceProfileEditUrl ($iContentId)
+    {
+        return bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $this->_oConfig->CNF['URI_EDIT_ENTRY'] . '&id=' . $iContentId));
+    }
+
+    public function serviceProfileThumb ($iContentId)
+    {
+        return $this->_serviceTemplateFunc('urlThumb', $iContentId);
+    }
+
+    public function serviceProfileIcon ($iContentId)
+    {
+        return $this->_serviceTemplateFunc('urlIcon', $iContentId);
+    }
+
+    public function serviceProfileName ($iContentId)
+    {
+        if (!$iContentId)
+            return false;
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if (!$aContentInfo)
+            return false;
+        
+        $sDisplayName = $this->getProfileName($aContentInfo);
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_profile-profile_name '{module_name}', 'profile_name' - hook to override profile display name
+         * - $unit_name - module name
+         * - $action - equals `profile_name`
+         * - $object_id - profile id
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `info` - [array] profile info array as key&value pairs
+         *      - `display_name` - [string] by ref, profile display name, can be overridden in hook processing
+         * @hook @ref hook-bx_base_profile-profile_name
+         */
+        bx_alert($this->getName(), 'profile_name', $aContentInfo['profile_id'], 0, [
+            'info' => $aContentInfo, 
+            'display_name' => &$sDisplayName
+        ]);
+        
+        return $sDisplayName;
+    }
+    
+    public function serviceProfileSettings ($iContentId)
+    {
+        if (!$iContentId)
+            return false;
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if (!$aContentInfo)
+            return false;
+        
+       if (isset($aContentInfo['settings']))
+           return json_decode($aContentInfo['settings']);
+    }
+    
+    public function serviceProfileSettingsSet ($iContentId, $oValue)
+    {
+        $CNF = $this->_oConfig->CNF;
+        $this->_oDb->updateEntriesBy(['settings' => json_encode($oValue)], [$CNF['FIELD_ID'] => $iContentId]);
+    }
+  
+    public function serviceProfileCreateUrl ($bAbsolute = true)
+    {
+    	$CNF = $this->_oConfig->CNF;
+    	if(empty($CNF['URL_CREATE']))
+    		return false;
+
+    	return $bAbsolute ? bx_absolute_url(BxDolPermalinks::getInstance()->permalink($CNF['URL_CREATE'])) : $CNF['URL_CREATE'];
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_profile Base Profile
+     * @subsection bx_base_profile-general General
+     * @subsubsection bx_base_profile-profile_url profile_url
+     * 
+     * @code bx_srv('bx_persons', 'profile_url', [...]); @endcode
+     * 
+     * Get profile URL
+     * @param $iContentId content ID
+     * 
+     * @see BxBaseModProfileModule::serviceProfileUrl
+     */
+    /** 
+     * @ref bx_base_profile-profile_url "profile_url"
+     */
+    public function serviceProfileUrl ($iContentId)
+    {
+        if (!$iContentId)
+            return false;
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if (!$aContentInfo)
+            return false;
+        $CNF = $this->_oConfig->CNF;
+        return bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $CNF['URI_VIEW_ENTRY'] . '&id=' . $aContentInfo[$CNF['FIELD_ID']]));
+    }
+
+    /**
+     * @see iBxDolProfileService::serviceGetSpaceTitle
+     */ 
+    public function serviceGetSpaceTitle()
+    {
+        return _t('_sys_ps_space_title_friend');
+    }
+
+    /**
+     * @see iBxDolProfileService::serviceGetParticipatingProfiles
+     */ 
+    public function serviceGetParticipatingProfiles($iProfileId, $aConnectionObjects = false)
+    {
+        if (false === $aConnectionObjects)
+            $aConnectionObjects = array('sys_profiles_friends');
+
+        $a = array();
+        foreach ($aConnectionObjects as $sConnectionObject) {
+            if (!($o = BxDolConnection::getObjectInstance($sConnectionObject)))
+                continue;
+
+            if (BX_CONNECTIONS_TYPE_MUTUAL == $o->getType())
+                $a = array_merge($a, $o->getConnectedContentByType($iProfileId, $this->getName(), true));
+            else
+                $a = array_merge($a, $o->getConnectedContentByType($iProfileId, $this->getName()));
+        }
+        $aRet = array_unique($a);
+
+        // the check for an ability to post in profile has been moved to BxDolPrivacy because we need to have an extended check
+        // with an ability to check for extended roles taking into account the type of content which is going be posted/edited
+        // so now this service returns just a fans, not filtered by an ability to post into an entry's context
+        /*foreach ($a as $iConnectedProfileId) {
+            if (!($oConnectedProfile = BxDolProfile::getInstance($iConnectedProfileId)))
+                continue;
+
+             if (CHECK_ACTION_RESULT_ALLOWED === bx_srv($oConnectedProfile->getModule(), 'check_allowed_post_in_profile', array($oConnectedProfile->getContentId(), $iProfileId)))
+                $aRet[] = $iConnectedProfileId;
+        }*/
+
+        /**
+         * @hooks
+         * @hookdef hook-system-get_participating_profiles 'system', 'get_participating_profiles' - hook to override context participants list
+         * - $unit_name - equals `system`
+         * - $action - equals `get_participating_profiles`
+         * - $object_id - context profile id
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `module` - [string] module name
+         *      - `profiles` - [array] by ref, a list of participating profiles ids, can be overridden in hook processing
+         * @hook @ref hook-system-get_participating_profiles
+         */
+        bx_alert('system', 'get_participating_profiles', $iProfileId, false, [
+            'module' => $this->_oConfig->getName(),
+            'profiles' => &$aRet
+        ]);
+
+        return $aRet;
+    }
+
+    /**
+     * Prepare fields from some universal set of fields to fields in particular profile module. 
+     * By default only 'name' and 'description' fields are supported.
+     * After fields convertion it can be used in @see BxBaseModGeneralModule::serviceEntityAdd
+     * @param $aFieldsProfile fields in soem universal format.
+     * @return array which is ready to use for particular module
+     */ 
+    public function servicePrepareFields ($aFieldsProfile)
+    {
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_profile-prepare_fields '{module_name}', 'prepare_fields' - hook to override prepared profile fields
+         * - $unit_name - module name
+         * - $action - equals `prepare_fields`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `fields_orig` - [array] original fields array as key&value pairs
+         *      - `fields_result` - [array] by ref, resulting fields array as key&value pairs, can be overridden in hook processing
+         * @hook @ref hook-bx_base_profile-prepare_fields
+         */
+        bx_alert($this->getName(), 'prepare_fields', 0, 0, [
+            'fields_orig' => $aFieldsProfile, 
+            'fields_result' => &$aFieldsProfile
+        ]);
+
+        return $aFieldsProfile;
+    }
+
+    protected function _servicePrepareFields ($aFieldsProfile, $aFieldsDefault, $aMap)
+    {
+        $aFieldsOrig = $aFieldsProfile;
+
+        bx_import('BxDolPrivacy');
+        $aFieldsDefault2 = array(
+            'allow_view_to' => BX_DOL_PG_ALL,
+            'allow_contact_to' => BX_DOL_PG_ALL, 
+            'allow_post_to' => BX_DOL_PG_FRIENDS,
+        );
+        $aFieldsProfile = array_merge($aFieldsDefault2, $aFieldsDefault, $aFieldsProfile);
+
+        foreach ($aMap as $k => $v) {
+            if (isset($aFieldsProfile[$v]))
+                $aFieldsProfile[$k] = $aFieldsProfile[$v];
+            if ($k != $v && !isset($aMap[$v]))
+                unset($aFieldsProfile[$v]);
+        }
+
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_profile-prepare_fields '{module_name}', 'prepare_fields' - hook to override prepared profile fields
+         * - $unit_name - module name
+         * - $action - equals `prepare_fields`
+         * - $object_id - not used
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `fields_orig` - [array] original fields array as key&value pairs
+         *      - `fields_result` - [array] by ref, resulting fields array as key&value pairs, can be overridden in hook processing
+         * @hook @ref hook-bx_base_profile-prepare_fields
+         */
+        bx_alert($this->getName(), 'prepare_fields', 0, 0, [
+            'fields_orig' => $aFieldsOrig, 
+            'fields_result' => &$aFieldsProfile
+        ]);
+
+        return $aFieldsProfile;
+    }
+
+    public function serviceFormsHelper ()
+    {
+        return parent::serviceFormsHelper ();
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_profile Base Profile
+     * @subsection bx_base_profile-forms Forms
+     * @subsubsection bx_base_profile-entity_create entity_create
+     * 
+     * @code bx_srv('bx_persons', 'entity_create', [...]); @endcode
+     * 
+     * Get profile creation form
+     * @param $sDisplay optional form display name
+     * 
+     * @see BxBaseModProfileModule::serviceEntityCreate
+     */
+    /** 
+     * @ref bx_base_profile-entity_create "entity_create"
+     */
+    public function serviceEntityCreate ($sDisplay = false)
+    {
+	    BxDolInformer::getInstance($this->_oTemplate)->setEnabled(false);
+
+	    return parent::serviceEntityCreate ($sDisplay);
+    }
+
+    public function serviceActAsProfile ()
+    {
+        return true;
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_profile Base Profile
+     * @subsection bx_base_profile-browse Browse
+     * @subsubsection bx_base_profile-browse_recommended browse_recommended
+     * 
+     * @code bx_srv('bx_persons', 'browse_recommended', [...]); @endcode
+     * 
+     * Browse recommended profiles
+     * @param $sUnitView optional, unit view, for example - showcase
+     * @param $bEmptyMessage optional, display or not "empty" message when there is no content
+     * @param $bAjaxPaginate optional, use AJAX paginate or not
+     * 
+     * @see BxBaseModProfileModule::serviceBrowseRecommended
+     */
+    /** 
+     * @ref bx_base_profile-browse_recommended "browse_recommended"
+     */
+    public function serviceBrowseRecommended ($sUnitView = false, $bEmptyMessage = false, $bAjaxPaginate = true)
+    {
+        if (!isLogged())
+            return '';
+        return $this->_serviceBrowse ('recommended', $sUnitView ? array('unit_view' => $sUnitView) : false, BX_DB_PADDING_DEF, $bEmptyMessage, $bAjaxPaginate);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_profile Base Profile
+     * @subsection bx_base_profile-browse Browse
+     * @subsubsection bx_base_profile-browse_recent_profiles browse_recent_profiles
+     * 
+     * @code bx_srv('bx_persons', 'browse_recent_profiles', [...]); @endcode
+     * 
+     * Browse recently joined profiles
+     * @param $bEmptyMessage optional, display or not "empty" message when there is no content
+     * @param $bAjaxPaginate optional, use AJAX paginate or not
+     * 
+     * @see BxBaseModProfileModule::serviceBrowseRecentProfiles
+     */
+    /** 
+     * @ref bx_base_profile-browse_recent_profiles "browse_recent_profiles"
+     */
+    public function serviceBrowseRecentProfiles ($bDisplayEmptyMsg = false, $bAjaxPaginate = true, $sUnitView = false)
+    {
+        return $this->_serviceBrowse ('recent', $sUnitView ? array('unit_view' => $sUnitView) : false, BX_DB_PADDING_DEF, $bDisplayEmptyMsg, $bAjaxPaginate);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_profile Base Profile
+     * @subsection bx_base_profile-browse Browse
+     * @subsubsection bx_base_profile-browse_active_profiles browse_active_profiles
+     * 
+     * @code bx_srv('bx_persons', 'browse_active_profiles', [...]); @endcode
+     * 
+     * Browse recently active profiles
+     * @param $bEmptyMessage optional, display or not "empty" message when there is no content
+     * @param $bAjaxPaginate optional, use AJAX paginate or not
+     * 
+     * @see BxBaseModProfileModule::serviceBrowseActiveProfiles
+     */
+    /** 
+     * @ref bx_base_profile-browse_active_profiles "browse_active_profiles"
+     */
+    public function serviceBrowseActiveProfiles ($sUnitView = false, $bEmptyMessage = false, $bAjaxPaginate = false)
+    {
+        return $this->_serviceBrowse ('active', $sUnitView ? array('unit_view' => $sUnitView) : false, BX_DB_PADDING_DEF, $bEmptyMessage, $bAjaxPaginate);
+    }
+    
+    /**
+     * @page service Service Calls
+     * @section bx_base_profile Base Profile
+     * @subsection bx_base_profile-browse Browse
+     * @subsubsection bx_base_profile-browse_familiar_profiles browse_familiar_profiles
+     * 
+     * @code bx_srv('bx_persons', 'browse_familiar_profiles', [...]); @endcode
+     * 
+     * Browse profiles you might be familiar with. By default using 'Profile Friends' connection.
+     * @param $bEmptyMessage optional, display or not "empty" message when there is no content
+     * @param $bAjaxPaginate optional, use AJAX paginate or not
+     * 
+     * @see BxBaseModProfileModule::serviceBrowseFamiliarProfiles
+     */
+    /** 
+     * @ref bx_base_profile-browse_familiar_profiles "browse_familiar_profiles"
+     */
+    public function serviceBrowseFamiliarProfiles ($sConnection = '', $sUnitView = false, $bEmptyMessage = false, $bAjaxPaginate = false)
+    {
+        $aParams = [
+            'object' => 'sys_profiles_friends',
+            'profile' => bx_get_logged_profile_id(),
+        ];
+
+        if(!empty($sConnection)) {
+            if(is_string($sConnection))
+                $aParams['object'] = $sConnection;
+            else if(is_array($sConnection))
+                $aParams = array_merge($aParams, $sConnection);
+        }
+
+        if($sUnitView)
+            $aParams['unit_view'] = $sUnitView;
+
+        return $this->_serviceBrowse ('familiar', $aParams, BX_DB_PADDING_DEF, $bEmptyMessage, $bAjaxPaginate);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_profile Base Profile
+     * @subsection bx_base_profile-browse Browse
+     * @subsubsection bx_base_profile-browse_top_profiles browse_top_profiles
+     * 
+     * @code bx_srv('bx_persons', 'browse_top_profiles', [...]); @endcode
+     * 
+     * Browse top profiles
+     * @param $bEmptyMessage optional, display or not "empty" message when there is no content
+     * @param $bAjaxPaginate optional, use AJAX paginate or not
+     * 
+     * @see BxBaseModProfileModule::serviceBrowseTopProfiles
+     */
+    /** 
+     * @ref bx_base_profile-browse_top_profiles "browse_top_profiles"
+     */
+    public function serviceBrowseTopProfiles ($bDisplayEmptyMsg = false, $bAjaxPaginate = false)
+    {
+        return $this->_serviceBrowse ('top', false, BX_DB_PADDING_DEF, $bDisplayEmptyMsg, $bAjaxPaginate);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_profile Base Profile
+     * @subsection bx_base_profile-browse Browse
+     * @subsubsection bx_base_profile-browse_online_profiles browse_online_profiles
+     * 
+     * @code bx_srv('bx_persons', 'browse_online_profiles', [...]); @endcode
+     * 
+     * Browse online profiles
+     * @param $bEmptyMessage optional, display or not "empty" message when there is no content
+     * @param $bAjaxPaginate optional, use AJAX paginate or not
+     * 
+     * @see BxBaseModProfileModule::serviceBrowseOnlineProfiles
+     */
+    /** 
+     * @ref bx_base_profile-browse_online_profiles "browse_online_profiles"
+     */
+    public function serviceBrowseOnlineProfiles ($bDisplayEmptyMsg = false, $bAjaxPaginate = false)
+    {
+        return $this->_serviceBrowse ('online', false, BX_DB_PADDING_DEF, $bDisplayEmptyMsg, $bAjaxPaginate);
+    }
+
+    public function serviceBrowseConnections ($iProfileId, $sObjectConnections = 'sys_profiles_friends', $sConnectionsType = 'content', $iMutual = false, $iDesignBox = BX_DB_PADDING_DEF, $iProfileId2 = 0)
+    {
+        return $this->_serviceBrowse (
+            'connections', [
+                'object' => $sObjectConnections,
+                'type' => $sConnectionsType,
+                'mutual' => $iMutual,
+                'profile' => (int)$iProfileId,
+                'profile2' => (int)$iProfileId2
+            ], $iDesignBox
+        );
+    }
+
+    public function serviceBrowseRelationsQuick ($iProfileId, $sObjectConnections = 'sys_profiles_friends', $sConnectionsType = 'content', $iMutual = false, $iProfileId2 = 0)
+    {
+        // get connections object
+        $oConnection = BxDolConnection::getObjectInstance($sObjectConnections);
+        if (!$oConnection)
+            return '';       
+
+        // set some vars
+        $iStart = (int)bx_get('start');
+        $iLimit = empty($this->_oConfig->CNF['PARAM_NUM_CONNECTIONS_QUICK']) ? 4 : getParam($this->_oConfig->CNF['PARAM_NUM_CONNECTIONS_QUICK']);
+        if (!$iLimit)
+            $iLimit = 4;
+
+        // get connections array
+        bx_import('BxDolConnection');
+        $aConnections = $oConnection->getConnectionsAsArrayExt($sConnectionsType, $iProfileId, $iProfileId2, $iMutual, $iStart, $iLimit + 1, BX_CONNECTIONS_ORDER_ADDED_DESC);
+        if(empty($aConnections) || !is_array($aConnections))
+            return '';
+
+        $aResult = array();
+        foreach($aConnections as $iProfile => $aConnection)
+            $aResult[] = array(
+                'id' => $iProfile,
+                'info' => array(
+                    'addon' => $oConnection->getRelationTranslation($aConnection['relation'])
+                )
+            );
+
+        return $this->_serviceBrowseQuick($aResult, $iStart, $iLimit);
+    }
+
+    public function serviceBrowseConnectionsQuick ($iProfileId, $sObjectConnections = 'sys_profiles_friends', $sConnectionsType = 'content', $iMutual = false, $iProfileId2 = 0)
+    {
+        // get connections object
+        $oConnection = BxDolConnection::getObjectInstance($sObjectConnections);
+        if (!$oConnection)
+            return '';
+
+        // set some vars
+        $iLimit = empty($this->_oConfig->CNF['PARAM_NUM_CONNECTIONS_QUICK']) ? 4 : getParam($this->_oConfig->CNF['PARAM_NUM_CONNECTIONS_QUICK']);
+        if (!$iLimit)
+            $iLimit = 4;
+        $iStart = (int)bx_get('start');
+
+        // get connections array
+        bx_import('BxDolConnection');
+        $a = $oConnection->getConnectionsAsArray ($sConnectionsType, $iProfileId, $iProfileId2, $iMutual, (int)bx_get('start'), $iLimit + 1, BX_CONNECTIONS_ORDER_ADDED_DESC);
+        if (!$a)
+            return '';
+
+        return $this->_serviceBrowseQuick($a, $iStart, $iLimit);
+    }
+
+    public function serviceBrowseConnectionsByType ($aParamsCnn, $aParamsBrs = array())
+    {
+        $sAll = 'all';
+
+        $aParamsCnn['object'] = !empty($aParamsCnn['object']) ? $aParamsCnn['object'] : 'sys_profiles_friends';
+        $aParamsCnn['type'] = !empty($aParamsCnn['type']) ? $aParamsCnn['type'] : 'content';
+        $aParamsCnn['profile_id2'] = !empty($aParamsCnn['profile_id2']) ? (int)$aParamsCnn['profile_id2'] : 0;
+        $aParamsCnn['mutual'] = isset($aParamsCnn['mutual']) ? $aParamsCnn['mutual'] : false;
+
+        $oConnection = BxDolConnection::getObjectInstance($aParamsCnn['object']);
+        if(!$oConnection)
+            return '';
+
+        $aSQLParts = $oConnection->getConnectionsAsSQLParts ($aParamsCnn['type'], 'sys_profiles', 'id', $aParamsCnn['profile_id'], $aParamsCnn['profile_id2'], $aParamsCnn['mutual']);
+
+        $sType = !empty($aParamsBrs['type']) ? $aParamsBrs['type'] : $sAll;
+        if(bx_get('type') !== false)
+            $sType = bx_process_input(bx_get('type'));
+
+        $iStart = (int)bx_get('start');
+
+        $iLimitDefault = 4;
+        $iLimit = !empty($this->_oConfig->CNF['PARAM_NUM_CONNECTIONS_QUICK']) ? getParam($this->_oConfig->CNF['PARAM_NUM_CONNECTIONS_QUICK']) : $iLimitDefault;
+        if(!empty($aParamsBrs['per_page']))
+            $iLimit = (int)$aParamsBrs['per_page'];
+        if(!$iLimit)
+            $iLimit = $iLimitDefault;
+
+        $aProfiles = BxDolProfileQuery::getInstance()->getConnectedProfilesByType($aSQLParts, ($sType != $sAll ? $sType : ''), $iStart, $iLimit + 1);
+
+        $mixedMenu = '';
+        if(!isset($aParamsBrs['filter_menu']) || $aParamsBrs['filter_menu'] === true) {
+            $aModules = array(
+                array('name' => $sAll)
+            );
+            $aModules = array_merge($aModules, BxDolModuleQuery::getInstance()->getModulesBy(array('type' => 'modules', 'active' => 1)));
+
+            $aMenuItems = array();
+            foreach($aModules as $aModule)
+                if($aModule['name'] == $sAll || BxDolRequest::serviceExists($aModule['name'], 'act_as_profile'))
+                    $aMenuItems[] = array(
+                    	'id' => $aModule['name'], 
+                    	'name' => $aModule['name'], 
+                    	'class' => '', 
+                    	'link' => 'javascript:void(0)', 
+                    	'onclick' => "return !loadDynamicBlockAutoPaginate(this, " . $iStart . ", " . $iLimit . ", " . bx_js_string(json_encode(array('type' => $aModule['name']))) . ");", 
+                    	'target' => '_self', 
+                    	'title' => _t('_' . $aModule['name']), 
+                    	'active' => 1
+                    );
+    
+            if(!empty($aMenuItems)) {
+                $mixedMenu = new BxTemplMenu(array('template' => 'menu_vertical.html', 'menu_id'=> $this->_oConfig->getName() . '-connections-by-type', 'menu_items' => $aMenuItems));
+                $mixedMenu->setSelected('', $sType);
+            }
+        }
+
+        return array(
+            'menu' => $mixedMenu,
+            'content' => $this->_serviceBrowseQuick(array_keys($aProfiles), $iStart, $iLimit, array('type' => $sType))
+        );
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_profile Base Profile
+     * @subsection bx_base_profile-browse Browse
+     * @subsubsection bx_base_profile-browse_by_acl browse_by_acl
+     * 
+     * @code bx_srv('bx_persons', 'browse_by_acl', [...]); @endcode
+     * 
+     * Browse profiles by membership(ACL) level
+     * @param $mixedLevelId membership level, or array of membership levels
+     * @param $iDesignBox optional, design box identifier
+     * 
+     * @see BxBaseModProfileModule::serviceBrowseByAcl
+     */
+    /** 
+     * @ref bx_base_profile-browse_by_acl "browse_by_acl"
+     */
+	public function serviceBrowseByAcl ($mixedLevelId, $iDesignBox = BX_DB_PADDING_DEF)
+    {
+        return $this->_serviceBrowse (
+            'acl',
+            array(
+                'level' => $mixedLevelId,
+			),
+            $iDesignBox,
+            true
+        );
+    }
+
+    public function serviceEntityEditCover($iContentId = 0)
+    {
+        return $this->_serviceEntityForm('editDataForm', $iContentId, $this->_oConfig->CNF['OBJECT_FORM_ENTRY_DISPLAY_EDIT_COVER']);
+    }
+    
+    public function serviceEntityEditSettings($iContentId = 0)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(empty($CNF['OBJECT_FORM_ENTRY_DISPLAY_EDIT_SETTINGS']))
+            return false;
+
+        return $this->_serviceEntityForm('editDataForm', $iContentId, $CNF['OBJECT_FORM_ENTRY_DISPLAY_EDIT_SETTINGS']);
+    }
+
+    /**
+     * @page service Service Calls
+     * @section bx_base_profile Base Profile
+     * @subsection bx_base_profile-page_blocks Page Blocks
+     * @subsubsection bx_base_profile-entity_cover entity_cover
+     * 
+     * @code bx_srv('bx_persons', 'entity_cover', [...]); @endcode
+     * 
+     * Get cover
+     * @param $iContentId content ID
+     * 
+     * @see BxBaseModProfileModule::serviceEntityCover
+     */
+    /** 
+     * @ref bx_base_general-entity_cover "entity_cover"
+     */
+    public function serviceEntityCover ($iContentId = 0, $aParams = [])
+    {
+        $mixedContent = $this->_getContent($iContentId);
+        if($mixedContent === false)
+            return false;
+
+        list($iContentId, $aContentInfo) = $mixedContent;
+
+        if($this->_bIsApi) {
+            $CNF = &$this->_oConfig->CNF;
+
+            $aContentInfo['cover'] = $this->serviceGetCover($iContentId);
+            $aContentInfo['image'] = $this->serviceGetThumb($iContentId);            
+
+            $sKey = 'OBJECT_MENU_VIEW_ENTRY_META';
+            if(!empty($CNF[$sKey]) && ($oMetaMenu = BxTemplMenu::getObjectInstance($CNF[$sKey])) !== false)
+                $aContentInfo['meta_menu'] = $oMetaMenu->getCodeAPI();
+
+            $sKey = 'OBJECT_MENU_ACTIONS_VIEW_ENTRY_ALL';
+            if(!empty($CNF[$sKey]) && ($oActionMenu = BxTemplMenu::getObjectInstance($CNF[$sKey])) !== false)
+                $aContentInfo['actions_menu'] = $oActionMenu->getCodeAPI();
+
+            if(($oMenuManage = BxDolMenu::getObjectInstance('sys_site_manage')) !== false) {
+                if(!empty($CNF['FIELD_ID']))
+                    $oMenuManage->setContentId($aContentInfo[$CNF['FIELD_ID']]);
+
+                $aObjectManage = [];
+                if(!empty($CNF['OBJECT_MENU_MANAGE_VIEW_ENTRY']))
+                    $aObjectManage = [$CNF['OBJECT_MENU_MANAGE_VIEW_ENTRY']];
+                else if(!empty($CNF['OBJECT_MENU_ACTIONS_VIEW_ENTRY_MORE']))
+                    $aObjectManage = [$CNF['OBJECT_MENU_ACTIONS_VIEW_ENTRY_MORE'], true];
+
+                call_user_func_array([$oMenuManage, 'setObjectManage'], $aObjectManage);
+
+                $aMenuManage = $oMenuManage->getCodeAPI();
+                if(!empty($aMenuManage) && is_array($aMenuManage)) {
+                    if(isset($aContentInfo['actions_menu']) && is_array($aContentInfo['actions_menu']))
+                        $aContentInfo['actions_menu']['items'] = array_merge($aContentInfo['actions_menu']['items'], $aMenuManage['items']);
+                    else
+                       $aContentInfo['actions_menu'] = $aMenuManage;
+                }   
+            }
+
+            if(($oProfile = BxDolProfile::getInstanceByContentAndType($iContentId, $this->getName())) !== false);
+                $aContentInfo['profile'] = BxDolProfile::getData($oProfile, [
+                    'get_avatar' => 'getAvatarBig',
+                    'with_info' => false
+                ]);
+
+            return [bx_api_get_block('entity_cover', $aContentInfo)];
+        }
+        
+        return $this->_oTemplate->getBlockCover($aContentInfo, $aParams);
+    }
+    
+	/**
+     * Entry comments
+     */
+    public function serviceEntityCommentsByProfile ($iProfileId)
+    {
+        $CNF = &$this->_oConfig->CNF;
+        if(empty($CNF['OBJECT_COMMENTS']))
+            return '';
+
+        $oProfile = BxDolProfile::getInstance($iProfileId);
+        if(!$oProfile)
+            return '';
+
+        return $this->_entityComments($CNF['OBJECT_COMMENTS'], $oProfile->getContentId());
+    }
+
+    /**
+     * Entry social sharing block
+     */
+    public function serviceEntitySocialSharing ($mixedContent = false, $aParams = array())
+    {
+        if(!empty($mixedContent)) {
+            if(!is_array($mixedContent))
+               $mixedContent = array((int)$mixedContent, array());
+        }
+        else {
+            $mixedContent = $this->_getContent();
+            if($mixedContent === false)
+                return false;
+        }
+
+        list($iContentId, $aContentInfo) = $mixedContent;    
+
+        return parent::serviceEntitySocialSharing(array($iContentId, $aContentInfo), array(
+            'id_thumb' => !empty($CNF['FIELD_PICTURE']) && !empty($aContentInfo[$CNF['FIELD_PICTURE']]) ? $aContentInfo[$CNF['FIELD_PICTURE']] : 0, 
+        ));
+    }
+
+    public function serviceProfileMembership ($iContentId = 0)
+    {
+    	$mixedContent = $this->_getContent($iContentId);
+        if($mixedContent === false)
+            return false;
+
+        list($iContentId, $aContentInfo) = $mixedContent;
+
+		return BxDolAcl::getInstance()->getProfileMembership($aContentInfo['profile_id']);
+    }
+
+    public function serviceProfileFriends ($iContentId = 0)
+    {
+        $mixedContent = $this->_getContent($iContentId);
+        if($mixedContent === false)
+            return false;
+
+        list($iContentId, $aContentInfo) = $mixedContent;
+
+        bx_import('BxDolConnection');
+        $s = $this->serviceBrowseConnectionsQuick ($aContentInfo['profile_id'], 'sys_profiles_friends', BX_CONNECTIONS_CONTENT_TYPE_CONTENT, true);
+        if (!$s)
+            return MsgBox(_t('_sys_txt_empty'));
+
+        return $s;
+    }
+
+    public function serviceProfileFriendsMutual ($iContentId = 0)
+    {
+        $mixedContent = $this->_getContent($iContentId);
+        if($mixedContent === false)
+            return false;
+
+        list($iContentId, $aContentInfo) = $mixedContent;
+
+        $iViewer = bx_get_logged_profile_id();
+        $iOwner = (int)$aContentInfo['profile_id'];
+        if($iViewer == $iOwner)
+            return false;
+
+        bx_import('BxDolConnection');
+        $s = $this->serviceBrowseConnectionsQuick ($iOwner, 'sys_profiles_friends', BX_CONNECTIONS_CONTENT_TYPE_COMMON, true, $iViewer);
+        if(!$s)
+            return MsgBox(_t('_sys_txt_empty'));
+
+        return $s;
+    }
+
+    public function serviceProfileSubscriptions ($iContentId = 0, $aParams = array())
+    {
+        $mixedContent = $this->_getContent($iContentId);
+        if($mixedContent === false)
+            return false;
+
+        list($iContentId, $aContentInfo) = $mixedContent;
+
+        bx_import('BxDolConnection');
+        $aResult = $this->serviceBrowseConnectionsByType(array(
+        	'profile_id' => $aContentInfo['profile_id'],  
+        	'object' => 'sys_profiles_subscriptions', 
+            'type' => BX_CONNECTIONS_CONTENT_TYPE_CONTENT
+        ), array(
+            'type' => isset($aParams['type']) ? $aParams['type'] : '',
+            'filter_menu' => isset($aParams['filter_menu']) ? $aParams['filter_menu'] : true,
+        ));
+        if(empty($aResult['content']))
+            $aResult['content'] = MsgBox(_t('_sys_txt_empty'));
+
+        return $aResult;
+    }
+
+    public function serviceProfileSubscribedMe ($iContentId = 0)
+    {
+        $mixedContent = $this->_getContent($iContentId);
+        if($mixedContent === false)
+            return false;
+
+        list($iContentId, $aContentInfo) = $mixedContent;
+
+        bx_import('BxDolConnection');
+        $s = $this->serviceBrowseConnectionsQuick ($aContentInfo['profile_id'], 'sys_profiles_subscriptions', BX_CONNECTIONS_CONTENT_TYPE_INITIATORS);
+        if (!$s)
+            return MsgBox(_t('_sys_txt_empty'));
+
+        return $s;
+    }
+
+    public function serviceProfileRelations ($iContentId = 0, $aParams = array())
+    {
+        if(!BxDolRelation::isEnabled())
+            return false;
+
+        $mixedContent = $this->_getContent($iContentId);
+        if($mixedContent === false)
+            return false;
+
+        list($iContentId, $aContentInfo) = $mixedContent;
+
+        bx_import('BxDolConnection');
+        $s = $this->serviceBrowseRelationsQuick ($aContentInfo['profile_id'], 'sys_profiles_relations', BX_CONNECTIONS_CONTENT_TYPE_CONTENT, 1);
+        if (!$s)
+            return MsgBox(_t('_sys_txt_empty'));
+
+        return $s;
+    }
+
+    public function serviceProfileRelatedMe ($iContentId = 0)
+    {
+        if(!BxDolRelation::isEnabled())
+            return false;
+
+        $mixedContent = $this->_getContent($iContentId);
+        if($mixedContent === false)
+            return false;
+
+        list($iContentId, $aContentInfo) = $mixedContent;
+
+        bx_import('BxDolConnection');
+        $s = $this->serviceBrowseRelationsQuick ($aContentInfo['profile_id'], 'sys_profiles_relations', BX_CONNECTIONS_CONTENT_TYPE_INITIATORS, 1);
+        if (!$s)
+            return MsgBox(_t('_sys_txt_empty'));
+
+        return $s;
+    }
+
+    /**
+     * check enabled profile activation letter
+     */
+    public function  serviceIsEnableProfileActivationLetter()
+    {
+        $CNF = &$this->_oConfig->CNF;
+        return isset($CNF['PARAM_ENABLE_ACTIVATION_LETTER']) ? (bool)getParam($CNF['PARAM_ENABLE_ACTIVATION_LETTER']) : true;
+    }
+
+    public function serviceIsEnableRelations()
+    {
+        if(!BxDolRelation::isEnabled())
+            return false;
+
+        $sModule = $this->_oConfig->getName();
+        $oRelations = BxDolConnection::getObjectInstance('sys_profiles_relations');
+        return $oRelations->isRelationAvailableWithProfile($sModule) || $oRelations->isRelationAvailableFromProfile($sModule);
+    }
+
+    /**
+     * For internal usage only.
+     */
+    public function serviceDeleteEntityService ($iContentId, $bDeleteWithContent = false)
+    {
+        return parent::serviceDeleteEntity ($iContentId, 'deleteDataService');
+    }
+
+	/**
+     * Data for Notifications module
+     */
+    public function serviceGetNotificationsData()
+    {
+        $a = parent::serviceGetNotificationsData();
+
+        $sModule = $this->_aModule['name'];
+        
+        $a['handlers'][] = array('group' => $sModule . '_timeline_post_common', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'timeline_post_common', 'module_name' => $sModule, 'module_method' => 'get_notifications_timeline_post_common', 'module_class' => 'Module');
+
+        $a['settings'][] = array('group' => 'timeline_post', 'unit' => $sModule, 'action' => 'timeline_post_common', 'types' => array('follow_member'));
+
+        $a['alerts'][] = array('unit' => $sModule, 'action' => 'timeline_post_common');
+
+        return $a;
+    }
+
+    public function serviceGetNotificationsPost($aEvent)
+    {
+        $aResult = parent::serviceGetNotificationsVote($aEvent);
+        if(empty($aResult) || !is_array($aResult) || !$this->serviceActAsProfile())
+            return $aResult;
+
+        $oProfile = BxDolProfile::getInstanceByContentAndType((int)$aEvent['object_id'], $this->_oConfig->getName());
+        if($oProfile !== false)
+            $aResult['entry_author'] = $oProfile->id();
+
+        return $aResult;
+    }
+
+    public function serviceGetNotificationsVote($aEvent)
+    {
+        $aResult = parent::serviceGetNotificationsVote($aEvent);
+        if(empty($aResult) || !is_array($aResult) || !$this->serviceActAsProfile())
+            return $aResult;
+
+        $oProfile = BxDolProfile::getInstanceByContentAndType((int)$aEvent['object_id'], $this->_oConfig->getName());
+        if($oProfile !== false)
+            $aResult['entry_author'] = $oProfile->id();
+
+        return $aResult;
+    }
+
+    /**
+     * Notification about new member requst in the group
+     */
+    public function serviceGetNotificationsTimelinePostCommon($aEvent)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $iContentId = (int)$aEvent['object_id'];
+        $oGroupProfile = BxDolProfile::getInstanceByContentAndType((int)$iContentId, $this->getName());
+        if(!$oGroupProfile)
+            return [];
+
+        $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+        if(empty($aContentInfo) || !is_array($aContentInfo))
+            return [];
+            
+        $aSubcontentInfo = BxDolService::call('bx_timeline', 'get_info', array((int)$aEvent['subobject_id'], false));
+        if(empty($aSubcontentInfo) || !is_array($aSubcontentInfo))
+            return [];
+
+        $sEntrySummary = isset($aContentInfo[$CNF['FIELD_TEXT']]) ? $aContentInfo[$CNF['FIELD_TEXT']] : '';
+
+        $sSubentryUrl = bx_absolute_url(str_replace(BX_DOL_URL_ROOT, '', BxDolService::call('bx_timeline', 'get_link', array((int)$aEvent['subobject_id']))), '{bx_url_root}');
+        $sSubentrySample = $aSubcontentInfo['title'];
+        if(empty($sSubentrySample))
+            $sSubentrySample = strmaxtextlen($aSubcontentInfo['description'], 20, '...');
+        $sSubentrySummary = $aSubcontentInfo['description'];
+
+        return [
+            'entry_sample' => $CNF['T']['txt_sample_single'],
+            'entry_url' => bx_absolute_url(str_replace(BX_DOL_URL_ROOT, '', $oGroupProfile->getUrl()), '{bx_url_root}'),
+            'entry_caption' => $oGroupProfile->getDisplayName(),
+            'entry_summary' => $sEntrySummary,
+            'entry_author' => $oGroupProfile->id(),
+            'subentry_sample' => $sSubentrySample,
+            'subentry_url' => $sSubentryUrl,
+            'subentry_summary' => $sSubentrySummary,
+            'lang_key' => $CNF['T']['txt_ntfs_timeline_post_common'],
+        ];
+    }
+
+    /**
+     * Data for Timeline module
+     */
+    public function serviceGetTimelineData()
+    {
+        $CNF = &$this->_oConfig->CNF;
+        $sModule = $this->_aModule['name'];
+
+        $aAlerts = array();
+        $aHandlers = array();
+        if(!empty($CNF['FIELD_PICTURE'])) {
+            $aAlerts = array_merge($aAlerts, array(
+                array('unit' => $sModule, 'action' => 'profile_picture_changed'),
+                array('unit' => $sModule, 'action' => 'profile_picture_deleted')
+            ));
+            $aHandlers = array_merge($aHandlers, array(
+                array('group' => $sModule . '_profile_picture', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'profile_picture_changed', 'module_name' => $sModule, 'module_method' => 'get_timeline_profile_picture', 'module_class' => 'Module',  'groupable' => 0, 'group_by' => ''),
+                array('group' => $sModule . '_profile_picture', 'type' => 'delete', 'alert_unit' => $sModule, 'alert_action' => 'profile_picture_deleted')
+            ));
+        }
+
+        if(!empty($CNF['FIELD_COVER'])) {
+            $aAlerts = array_merge($aAlerts, array(
+                array('unit' => $sModule, 'action' => 'profile_cover_changed'),
+                array('unit' => $sModule, 'action' => 'profile_cover_deleted')
+            ));
+            $aHandlers = array_merge($aHandlers, array(
+                array('group' => $sModule . '_profile_cover', 'type' => 'insert', 'alert_unit' => $sModule, 'alert_action' => 'profile_cover_changed', 'module_name' => $sModule, 'module_method' => 'get_timeline_profile_cover', 'module_class' => 'Module',  'groupable' => 0, 'group_by' => ''),
+                array('group' => $sModule . '_profile_cover', 'type' => 'delete', 'alert_unit' => $sModule, 'alert_action' => 'profile_cover_deleted')
+            ));
+        }
+
+    	$aResult = array();
+        if(!empty($aAlerts) && !empty($aHandlers))
+            $aResult = array(
+            	'handlers' => $aHandlers,
+            	'alerts' => $aAlerts
+            );
+
+        return $aResult;
+    }
+
+    /**
+     * Entry post for Timeline module
+     */
+    public function serviceGetTimelineProfilePicture($aEvent, $aBrowseParams = [])
+    {
+        $aResult = $this->_serviceGetTimelineProfileImage($aEvent, $aBrowseParams, [
+            'stg' => 'OBJECT_STORAGE',
+            'trans' => ['OBJECT_IMAGES_TRANSCODER_GALLERY', 'OBJECT_IMAGES_TRANSCODER_AVATAR'],
+            'trans_orig' => ['OBJECT_IMAGES_TRANSCODER_PICTURE', 'OBJECT_IMAGES_TRANSCODER_GALLERY'],
+            'txt_ss' => 'txt_sample_pp_single',
+            'txt_sswa' => 'txt_sample_pp_single_with_article',
+            'txt_sa' => 'txt_sample_pi_action',
+            'txt_sau' => 'txt_sample_pi_action_user'
+        ]);
+
+        if($aResult !== false)
+            $aResult['allowed_view'] = ['module' => $this->_oConfig->getName(), 'method' => 'get_timeline_profile_picture_allowed_view'];
+
+        return $aResult;
+    }
+
+    public function serviceGetTimelineProfilePictureAllowedView($aEvent)
+    {
+        return $this->_serviceGetTimelineProfileImageAllowedView($aEvent);
+    }
+
+    public function serviceGetTimelineProfileCover($aEvent, $aBrowseParams = [])
+    {
+        $aResult = $this->_serviceGetTimelineProfileImage($aEvent, $aBrowseParams, [
+            'stg' => 'OBJECT_STORAGE_COVER',
+            'trans' => ['OBJECT_IMAGES_TRANSCODER_GALLERY', 'OBJECT_IMAGES_TRANSCODER_COVER_THUMB'],
+            'trans_orig' => ['OBJECT_IMAGES_TRANSCODER_COVER', 'OBJECT_IMAGES_TRANSCODER_GALLERY'],
+            'txt_ss' => 'txt_sample_pc_single',
+            'txt_sswa' => 'txt_sample_pc_single_with_article',
+            'txt_sa' => 'txt_sample_pi_action',
+            'txt_sau' => 'txt_sample_pi_action_user'
+        ]);
+
+        if($aResult !== false)
+            $aResult['allowed_view'] = ['module' => $this->_oConfig->getName(), 'method' => 'get_timeline_profile_cover_allowed_view'];
+
+        return $aResult;
+    }
+    
+    public function serviceGetTimelineProfileCoverAllowedView($aEvent)
+    {
+        return $this->_serviceGetTimelineProfileImageAllowedView($aEvent);
+    }
+
+    public function serviceGetMenuItemTitleByConnection($sConnection, $sAction, $iContentProfileId, $iInitiatorProfileId = 0)
+    {
+        return $this->getMenuItemTitleByConnection($sConnection, $sAction, $iContentProfileId, $iInitiatorProfileId);
+    }
+
+
+    // ====== PERMISSION METHODS
+    /**
+     * @see iBxDolProfileService::serviceCheckAllowedProfileView
+     */ 
+    public function serviceCheckAllowedProfileView($iContentId)
+    {        
+        return $this->serviceCheckAllowedWithContent('View', $iContentId);
+    }
+
+    /**
+     * @see iBxDolProfileService::serviceCheckAllowedProfileContact
+     */ 
+    public function serviceCheckAllowedProfileContact($iContentId)
+    {        
+        return $this->serviceCheckAllowedWithContent('Contact', $iContentId);
+    }
+
+    /**
+     * @see iBxDolProfileService::serviceCheckAllowedPostInProfile
+     */
+    public function serviceCheckAllowedPostInProfile($iContentId, $sPostModule = '')
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!empty($CNF['FIELD_ALLOW_VIEW_TO']) && getParam('sys_treat_cxt_in_cxt_as_cnt') == 'on') {
+            $aContentInfo = $this->_oDb->getContentInfoById($iContentId);
+            if($aContentInfo && isset($aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']]) && (int)$aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']] < 0)
+                return _t('_sys_txt_access_denied');
+        }
+
+        // for groups based profiles we do have a Role permissions which have a higher priority than the site-wide permissions.
+        if (method_exists($this, 'isAllowedModuleActionByProfile')) {
+            $bResult = $this->isAllowedModuleActionByProfile($iContentId, $sPostModule, 'post');
+            // if a profile is having a role and a role is having permissions set then it overrides the site-wide setting.
+            if ($bResult !== NULL) return $bResult;
+        }
+
+        return $this->serviceCheckAllowedWithContent('Post', $iContentId);
+    }
+
+    /**
+     * @see iBxDolProfileService::serviceCheckAllowedModuleActionInProfile
+     */
+    public function serviceCheckAllowedModuleActionInProfile($iContentId, $sPostModule, $sActionName)
+    {
+        if (method_exists($this, 'isAllowedModuleActionByProfile')) {
+            $bResult = $this->isAllowedModuleActionByProfile($iContentId, $sPostModule, $sActionName);
+            if ($bResult !== NULL) return $bResult;
+        }
+
+        return _t('_Access denied');
+    }
+
+    /**
+     * @see iBxDolProfileService::serviceCheckSpacePrivacy
+     */ 
+    public function serviceCheckSpacePrivacy($iContentId)
+    {
+        return $this->serviceCheckAllowedProfileView($iContentId);
+    }
+
+    /**
+     * Check if the profile can be viewed.
+     * 
+     * NOTE. This service should be used if it's needed to pass some specific values in 
+     * $isPerformAction and $iProfileId parameters, otherwise it's recommended to use 
+     * BxBaseModProfileModule::serviceCheckAllowedProfileView service method or 
+     * BxDolProfile::checkAllowedProfileView method.
+     * 
+     * @param type $aDataEntry - entry which the action will be performed for
+     * @param type $isPerformAction - perform or just check the action
+     * @param type $iProfileId - performer's profile ID
+     * @return integer - one of CHECK_ACTION_RESULT_XXX constants.
+     */
+    public function serviceCheckAllowedViewForProfile ($aDataEntry, $isPerformAction = false, $iProfileId = false)
+    {
+        if (!$iProfileId)
+            $iProfileId = $this->_iProfileId;
+
+        if(empty($aDataEntry) || !is_array($aDataEntry))
+            return _t('_sys_txt_not_found');
+
+        $oProfile = BxDolProfile::getInstanceByContentAndType($aDataEntry[$this->_oConfig->CNF['FIELD_ID']], $this->getName());
+        if ($oProfile && $oProfile->id() == $iProfileId)
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        return parent::serviceCheckAllowedViewForProfile ($aDataEntry, $isPerformAction, $iProfileId);
+    }
+
+    /**
+     * Check if posting (comment, post in Timeline) is available.
+     * 
+     * NOTE. This service should be used if it's needed to pass some specific values in 
+     * $isPerformAction and $iProfileId parameters, otherwise it's recommended to use 
+     * BxBaseModProfileModule::serviceCheckAllowedPostInProfile service method or 
+     * BxDolProfile::checkAllowedPostInProfile method.
+     * 
+     * @param type $aDataEntry - entry which the action will be performed for
+     * @param type $isPerformAction - perform or just check the action
+     * @param type $iProfileId - performer's profile ID
+     * @return integer - one of CHECK_ACTION_RESULT_XXX constants.
+     */
+    public function serviceCheckAllowedPostForProfile ($aDataEntry, $isPerformAction = false, $iProfileId = false)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!$iProfileId)
+            $iProfileId = $this->_iProfileId;
+
+        // check is view allowed
+        if(($mixedResult = $this->serviceCheckAllowedViewForProfile($aDataEntry, $isPerformAction, $iProfileId)) !== CHECK_ACTION_RESULT_ALLOWED)
+            return $mixedResult;
+
+        // moderator and owner always have access
+        $oProfile = BxDolProfile::getInstanceByContentAndType($aDataEntry[$CNF['FIELD_ID']], $this->getName());
+        if(($oProfile && $oProfile->id() == $iProfileId) || $this->_isModerator($isPerformAction))
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        // check privacy
+        if(!empty($CNF['OBJECT_PRIVACY_POST'])) {
+            $oPrivacy = BxDolPrivacy::getObjectInstance($CNF['OBJECT_PRIVACY_POST']);
+            if($oPrivacy && !$oPrivacy->check($aDataEntry[$CNF['FIELD_ID']], $iProfileId))
+                return _t('_sys_access_denied_to_private_content');
+        }
+
+        // check alert to allow custom checks
+        $mixedResult = null;
+
+        /**
+         * @hooks
+         * @hookdef hook-system-check_allowed_post 'system', 'check_allowed_post' - hook to override the result of checking whether 'post' action is allowed or not to specified profile
+         * It's equivalent to @ref hook-system-check_allowed_view
+         * @hook @ref hook-system-check_allowed_post
+         */
+        bx_alert('system', 'check_allowed_post', 0, 0, [
+            'module' => $this->getName(), 
+            'content_info' => $aDataEntry, 
+            'profile_id' => $iProfileId, 
+            'override_result' => &$mixedResult
+        ]);
+        if($mixedResult !== null)
+            return $mixedResult;
+
+        return CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    /**
+     * Check if profile can be contacted.
+     * 
+     * NOTE. This service should be used if it's needed to pass some specific values in 
+     * $isPerformAction and $iProfileId parameters, otherwise it's recommended to use 
+     * BxBaseModProfileModule::serviceCheckAllowedProfileContact service method or 
+     * BxDolProfile::checkAllowedProfileContact method.
+     * 
+     * @param type $aDataEntry - entry which the action will be performed for
+     * @param type $isPerformAction - perform or just check the action
+     * @param type $iProfileId - performer's profile ID
+     * @return integer - one of CHECK_ACTION_RESULT_XXX constants.
+     */
+    public function serviceCheckAllowedContactForProfile ($aDataEntry, $isPerformAction = false, $iProfileId = false)
+    {
+        $mixedResult = $this->_serviceCheckAllowedContactForProfile($aDataEntry, $isPerformAction, $iProfileId);
+
+        /**
+         * @hooks
+         * @hookdef hook-system-check_allowed_contact 'system', 'check_allowed_contact' - hook to override the result of checking whether 'contact' action is allowed or not to specified profile
+         * It's equivalent to @ref hook-system-check_allowed_view
+         * @hook @ref hook-system-check_allowed_contact
+         */
+        bx_alert('system', 'check_allowed_contact', 0, 0, [
+            'module' => $this->getName(), 
+            'content_info' => $aDataEntry, 
+            'profile_id' => $iProfileId, 
+            'override_result' => &$mixedResult
+        ]);
+
+        return $mixedResult;
+    }
+
+    public function serviceSetViewProfileCover($oPage, $aProfileInfo)
+    {
+        $this->_oTemplate->setCover($oPage,$aProfileInfo);
+    }
+    
+    /** 
+     * Returns list of members by mode with limited number of records for React Jot
+     * @param string $sMode
+     * @param int $iStart
+     * @param int $iPerPage
+     * @return mixed
+     */
+    public function serviceGetMembers($sMode = 'active', $iStart = 0, $iPerPage = 10){
+        bx_import('SearchResult', $this->_aModule);
+        $sClass = $this->_aModule['class_prefix'] . 'SearchResult';
+        $o = new $sClass($sMode);
+
+        $o -> aCurrent['paginate'] = array('perPage' => $iPerPage, 'forceStart' => $iStart);
+        return $o -> getSearchData();
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden. So make sure to make strict(===) checking.
+     */
+    public function checkAllowedAdd ($isPerformAction = false)
+    {
+        if($this->serviceActAsProfile() && ($oAccount = BxDolAccount::getInstance()) !== false && $oAccount->isProfilesLimitReached())
+            return _t('_sys_txt_access_denied');
+
+        return parent::checkAllowedAdd ($isPerformAction);
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden. So make sure to make strict(===) checking.
+     */
+    public function checkAllowedView ($aDataEntry, $isPerformAction = false)
+    {
+        return $this->serviceCheckAllowedViewForProfile ($aDataEntry, $isPerformAction);
+    }
+    
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedViewProfileImage ($aDataEntry, $isPerformAction = false)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        // check privacy
+        if (empty($CNF['OBJECT_PRIVACY_VIEW']) || isAdmin() || CHECK_ACTION_RESULT_ALLOWED === $this->checkAllowedEditAnyEntry())
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        $oPrivacy = BxDolPrivacy::getObjectInstance($CNF['OBJECT_PRIVACY_VIEW']);
+        if ($oPrivacy && !$oPrivacy->check($aDataEntry[$CNF['FIELD_ID']]) && !$oPrivacy->isPartiallyVisible($aDataEntry[$CNF['FIELD_ALLOW_VIEW_TO']]))
+            return _t('_sys_access_denied_to_private_content');
+
+        return CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedViewCoverImage ($aDataEntry, $isPerformAction = false)
+    {
+        return $this->checkAllowedViewProfileImage($aDataEntry);
+    }
+    
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedPost ($aDataEntry, $isPerformAction = false)
+    {
+        return $this->serviceCheckAllowedPostForProfile ($aDataEntry, $isPerformAction);
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedContact ($aDataEntry, $isPerformAction = false)
+    {
+        return $this->serviceCheckAllowedContactForProfile ($aDataEntry, $isPerformAction);
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedEdit ($aDataEntry, $isPerformAction = false)
+    {
+        if(empty($aDataEntry) || !is_array($aDataEntry))
+            return _t('_sys_txt_not_found');
+
+        // moderator always has access
+        if ($this->_isModerator($isPerformAction))
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        // owner (checked by account instead of author!) always have access
+        $oProfile = BxDolProfile::getInstanceByContentAndType($aDataEntry[$this->_oConfig->CNF['FIELD_ID']], $this->_aModule['name']);
+        if (!$oProfile)
+            return _t('_sys_txt_error_occured');
+
+        if ($oProfile->id() == $this->_iProfileId || $oProfile->getAccountId() == $this->_iAccountId)
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        return _t('_sys_txt_access_denied');
+    }
+
+    /**
+     * Check if user can change cover image
+     */
+    public function checkAllowedChangeCover ($aDataEntry, $isPerformAction = false)
+    {
+        // moderator always has access
+        if ($this->_isModerator($isPerformAction))
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        // owner (checked by account instead of author!) always have access
+        $oProfile = BxDolProfile::getInstanceByContentAndType($aDataEntry[$this->_oConfig->CNF['FIELD_ID']], $this->_aModule['name']);
+        if (!$oProfile)
+            return _t('_sys_txt_error_occured');
+
+        if ($oProfile->id() == $this->_iProfileId || $oProfile->getAccountId() == $this->_iAccountId)
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        return _t('_sys_txt_access_denied');
+    }
+
+    /**
+     * Check if user can change settings
+     */
+    public function checkAllowedChangeSettings ($aDataEntry, $isPerformAction = false)
+    {
+        // moderator always has access
+        if ($this->_isModerator($isPerformAction))
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        // owner (checked by account instead of author!) always have access
+        $oProfile = BxDolProfile::getInstanceByContentAndType($aDataEntry[$this->_oConfig->CNF['FIELD_ID']], $this->_aModule['name']);
+        if (!$oProfile)
+            return _t('_sys_txt_error_occured');
+
+        if ($oProfile->id() == $this->_iProfileId || $oProfile->getAccountId() == $this->_iAccountId)
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        return _t('_sys_txt_access_denied');
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedDelete (&$aDataEntry, $isPerformAction = false)
+    {
+        // moderator always has access
+        if ($this->_isModerator($isPerformAction))
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        // owner (checked by account instead of author!) always have access
+        $aCheck = checkActionModule($this->_iProfileId, 'delete entry', $this->getName(), $isPerformAction);
+
+        $oProfile = BxDolProfile::getInstanceByContentAndType($aDataEntry[$this->_oConfig->CNF['FIELD_ID']], $this->_aModule['name'], $isPerformAction);
+        if (!$oProfile)
+            return _t('_sys_txt_error_occured');
+
+        if (($oProfile->id() == $this->_iProfileId || $oProfile->getAccountId() == $this->_iAccountId) && $aCheck[CHECK_ACTION_RESULT] === CHECK_ACTION_RESULT_ALLOWED)
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        return _t('_sys_txt_access_denied');
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedViewMoreMenu (&$aDataEntry, $isPerformAction = false)
+    {
+        $oMenu = BxTemplMenu::getObjectInstance($this->_oConfig->CNF['OBJECT_MENU_ACTIONS_VIEW_ENTRY_MORE']);
+        if (!$oMenu || !$oMenu->getCode())
+            return _t('_sys_txt_access_denied');
+        return CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    /**
+     * Compose differs from Contact. For example, a user should NOT be able to start conversation (compose) 
+     * with himself but he should be able to view conversation page where he is one of the participants.
+     * 
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedCompose(&$aDataEntry, $isPerformAction = false)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $oProfile = BxDolProfile::getInstanceByContentAndType($aDataEntry[$CNF['FIELD_ID']], $this->getName());
+        if($oProfile && $oProfile->id() == $this->_iProfileId)
+            return _t('_sys_txt_access_denied');
+
+        return $this->checkAllowedContact($aDataEntry, $isPerformAction);
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedFriendAdd (&$aDataEntry, $isPerformAction = false)
+    {
+        return $this->_checkAllowedConnect ($aDataEntry, $isPerformAction, ['sys_profiles_friends', 'checkAllowedAddConnection'], false, false);
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedFriendRemove (&$aDataEntry, $isPerformAction = false)
+    {
+        if (CHECK_ACTION_RESULT_ALLOWED === $this->_checkAllowedConnect ($aDataEntry, $isPerformAction, ['sys_profiles_friends', 'checkAllowedRemoveConnection'], false, true, true))
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        return $this->_checkAllowedConnect ($aDataEntry, $isPerformAction, ['sys_profiles_friends', 'checkAllowedRemoveConnection'], false, true, false);
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedRelationAdd (&$aDataEntry, $isPerformAction = false)
+    {
+        $sResult = _t('_sys_txt_access_denied');
+        if(!BxDolRelation::isEnabled())
+            return $sResult;
+
+        if(($sMsg = $this->checkAllowedView($aDataEntry)) !== CHECK_ACTION_RESULT_ALLOWED)
+            return $sMsg;
+
+        return $this->_checkAllowedConnect ($aDataEntry, $isPerformAction, 'sys_profiles_relations', false, false);
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedRelationRemove (&$aDataEntry, $isPerformAction = false)
+    {
+        $sResult = _t('_sys_txt_access_denied');
+        if(!BxDolRelation::isEnabled())
+            return $sResult;
+
+        return $this->_checkAllowedConnect ($aDataEntry, $isPerformAction, 'sys_profiles_relations', false, true);
+    }
+
+    public function checkAllowedRelationsView (&$aDataEntry, $isPerformAction = false)
+    {
+        $sResult = _t('_sys_txt_access_denied');
+        if(!BxDolRelation::isEnabled())
+            return $sResult;
+
+        $sModule = $this->_oConfig->getName();
+        $oRelations = BxDolConnection::getObjectInstance('sys_profiles_relations');
+        if(!$oRelations->isRelationAvailableWithProfile($sModule) && !$oRelations->isRelationAvailableFromProfile($sModule))
+            return $sResult;
+
+        if(empty($aDataEntry) || !is_array($aDataEntry))
+            return $sResult;
+
+        return CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedSubscribeAdd(&$aDataEntry, $isPerformAction = false)
+    {
+        $mixedResult = $this->_modProfileCheckAllowedSubscribeAdd($aDataEntry, $isPerformAction);
+
+        /**
+         * @hooks
+         * @hookdef hook-system-check_allowed_subscribe_add 'system', 'check_allowed_subscribe_add' - hook to override the result of checking whether 'subscribe' action is allowed or not to currently logged in user
+         * It's equivalent to @ref hook-system-check_allowed_view
+         * @hook @ref hook-system-check_allowed_subscribe_add
+         */
+        bx_alert('system', 'check_allowed_subscribe_add', 0, 0, [
+            'module' => $this->getName(), 
+            'content_info' => $aDataEntry, 
+            'profile_id' => bx_get_logged_profile_id(), 
+            'override_result' => &$mixedResult
+        ]);
+
+        return $mixedResult;
+    }
+
+    /**
+     * Note. Is mainly needed for internal usage. Access level is 'public' to allow outer calls from alerts.
+     */
+    public function _modProfileCheckAllowedSubscribeAdd(&$aDataEntry, $isPerformAction = false)
+    {
+        if(($sMsg = $this->checkAllowedView($aDataEntry)) !== CHECK_ACTION_RESULT_ALLOWED)
+            return $sMsg;
+
+        return $this->_checkAllowedConnect($aDataEntry, $isPerformAction, 'sys_profiles_subscriptions', false, false);
+    }
+
+    /**
+     * @return CHECK_ACTION_RESULT_ALLOWED if access is granted or error message if access is forbidden.
+     */
+    public function checkAllowedSubscribeRemove (&$aDataEntry, $isPerformAction = false)
+    {
+        return $this->_checkAllowedConnect ($aDataEntry, $isPerformAction, 'sys_profiles_subscriptions', false, true);
+    }
+
+    public function checkAllowedSubscriptionsView (&$aDataEntry, $isPerformAction = false)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $sResult = _t('_sys_txt_access_denied');
+        if(empty($aDataEntry) || !is_array($aDataEntry))
+            return $sResult;
+
+        $oProfile = BxDolProfile::getInstanceByContentAndType($aDataEntry[$CNF['FIELD_ID']], $this->_aModule['name']);
+        if(!$oProfile || ($oProfile->id() != $this->_iProfileId && $this->_oDb->getParam($CNF['PARAM_PUBLIC_SBSN']) != 'on' && $this->_oDb->getParam($CNF['PARAM_PUBLIC_SBSD']) != 'on'))
+            return $sResult;
+
+        return CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    public function checkMyself($iContentId)
+    {
+		$iLogged = (int)bx_get_logged_profile_id();
+    	if(empty($iLogged))
+    		return false;
+
+    	$oProfile = BxDolProfile::getInstanceByContentAndType((int)$iContentId, $this->_oConfig->getName());
+    	if(!$oProfile)
+    		return false;
+
+		return $oProfile->id() == $iLogged;
+    }
+    
+    public function isAllowDeleteOrDisable($iActorProfileId, $iTargetProfileId)
+    {
+        if (BxDolAcl::getInstance()->isMemberLevelInSet(array(MEMBERSHIP_ID_MODERATOR), $iActorProfileId) && BxDolAcl::getInstance()->isMemberLevelInSet(array(MEMBERSHIP_ID_MODERATOR, MEMBERSHIP_ID_ADMINISTRATOR), $iTargetProfileId))
+            return false;
+        
+        return true;    
+    }
+    
+    public function getProfileObject($iContentId)
+    {
+    	$oProfile = BxDolProfile::getInstanceByContentAndType((int)$iContentId, $this->_oConfig->getName());
+        if (!$oProfile) 
+            $oProfile = BxDolProfileUndefined::getInstance();
+
+        return $oProfile;
+    }
+
+    protected function _serviceCheckAllowedContactForProfile($aDataEntry, $isPerformAction = false, $iProfileId = false)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!$iProfileId)
+            $iProfileId = $this->_iProfileId;
+
+        /*
+         * allow to 'contact myself'. It's needed to allow a user to view conversation page 
+         * where he is one of the participants.
+         */
+        $oProfile = BxDolProfile::getInstanceByContentAndType($aDataEntry[$CNF['FIELD_ID']], $this->getName());
+        if($oProfile && $oProfile->id() == $iProfileId)
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        // moderator always has access
+        if($this->_isModeratorForProfile($isPerformAction, $iProfileId))
+            return CHECK_ACTION_RESULT_ALLOWED;
+
+        // check privacy
+        if(!empty($CNF['OBJECT_PRIVACY_CONTACT'])) {
+            $oPrivacy = BxDolPrivacy::getObjectInstance($CNF['OBJECT_PRIVACY_CONTACT']);
+            if($oPrivacy && !$oPrivacy->check($aDataEntry[$CNF['FIELD_ID']], $iProfileId))
+                return _t('_sys_access_denied_to_private_content');
+        }
+
+        return CHECK_ACTION_RESULT_ALLOWED;
+    }
+
+    // ====== COMMON METHODS
+    public function isMenuItemVisible($sObject, &$aItem, &$aContentInfo)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $bResult = parent::isMenuItemVisible($sObject, $aItem, $aContentInfo);
+        if(!$bResult)
+            return $bResult;
+
+        if(($sField = 'FIELD_STG_TABS') && !empty($CNF[$sField]) && ($sTabs = $aContentInfo[$CNF[$sField]])) {
+            $aTabs = explode(',', $sTabs);
+            if(in_array($aItem['name'], $aTabs))
+                return false;
+        }
+
+        return $bResult;
+    }
+
+    public function onUpdateImage($iContentId, $sFiledName, $sFiledValue, $iProfileId = 0)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $sModule = $this->getName();
+
+        if(!$iProfileId && ($oProfile = BxDolProfile::getInstanceByContentAndType($iContentId, $sModule)) !== false)
+            $iProfileId = $oProfile->id();
+
+        $aField2Method = [
+            $CNF['FIELD_PICTURE'] => 'picture',
+            $CNF['FIELD_COVER'] => 'cover',
+        ];
+
+        if(!empty($aField2Method[$sFiledName]))
+            /**
+             * @hooks
+             * @hookdef hook-bx_base_profile-profile_picture_changed '{module_name}', 'profile_picture_changed' - hook after profile picture was changed
+             * - $unit_name - module name
+             * - $action - equals `profile_picture_changed`
+             * - $object_id - image id
+             * - $sender_id - profile id who performed the action
+             * - $extra_params - array of additional params with the following array keys:
+             *      - `object_author_id` - [int] profile id
+             *      - `content` - [int] profile content id
+             *      - `field` - [string] field name
+             * @hook @ref hook-bx_base_profile-profile_picture_changed
+             */
+            /**
+             * @hooks
+             * @hookdef hook-bx_base_profile-profile_cover_changed '{module_name}', 'profile_cover_changed' - hook after profile cover was changed
+             * It's equivalent to @ref hook-bx_base_profile-profile_picture_changed
+             * @hook @ref hook-bx_base_profile-profile_cover_changed
+             */
+            bx_alert($sModule, 'profile_' . $aField2Method[$sFiledName] . '_changed', $sFiledValue, $iProfileId, [
+                'object_author_id' => $iProfileId, 
+                'content' => $iContentId, 
+                'field' => $sFiledName
+            ]);
+    }
+
+    protected function _alertParams($aContentInfo)
+    {
+        $aParams = parent::_alertParams($aContentInfo);
+
+        $CNF = &$this->_oConfig->CNF;
+
+        if(!empty($CNF['FIELD_STATUS']) && isset($aContentInfo[$CNF['FIELD_STATUS']]))
+            $aParams['status'] = $aContentInfo[$CNF['FIELD_STATUS']];
+
+        if(!empty($CNF['FIELD_STATUS_ADMIN']) && isset($aContentInfo[$CNF['FIELD_STATUS_ADMIN']]))
+            $aParams['status_admin'] = $aContentInfo[$CNF['FIELD_STATUS_ADMIN']];
+
+        if(!empty($CNF['FIELD_ALLOW_VIEW_TO']) && isset($aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']]))
+            $aParams['privacy_view'] = $aContentInfo[$CNF['FIELD_ALLOW_VIEW_TO']];
+
+        return $aParams;
+    }
+
+    public function alertAfterAdd($aContentInfo)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $iContentId = (int)$aContentInfo[$CNF['FIELD_ID']];
+
+        $aParams = $this->_alertParams($aContentInfo);
+        
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_profile-added '{module_name}', 'added' - hook after profile was added
+         * - $unit_name - module name
+         * - $action - equals `added`
+         * - $object_id - profile content id
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `status` - [string] context status
+         *      - `status_admin` - [string] context admin status
+         *      - `privacy_view` - [int] or [string] privacy for view context action, @see BxDolPrivacy
+         * @hook @ref hook-bx_base_profile-added
+         */
+        bx_alert($this->getName(), 'added', $iContentId, false, $aParams);
+    }
+
+    public function alertAfterEdit($aContentInfo)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $sModule = $this->getName();
+        $iContentId = (int)$aContentInfo[$CNF['FIELD_ID']];
+
+        $aParams = $this->_alertParams($aContentInfo);
+        /**
+         * @hooks
+         * @hookdef hook-bx_base_profile-edited '{module_name}', 'edited' - hook after profile was changed
+         * It's equivalent to @ref hook-bx_base_profile-added
+         * @hook @ref hook-bx_base_profile-edited
+         */
+        bx_alert($sModule, 'edited', $iContentId, false, $aParams);
+
+        $oProfile = BxDolProfile::getInstanceByContentAndType($iContentId, $sModule);
+        
+        /**
+         * @hooks
+         * @hookdef hook-profile-edit 'profile', 'edit' - hook after profile was changed
+         * - $unit_name - equals `profile`
+         * - $action - equals `edit`
+         * - $object_id - profile id
+         * - $sender_id - not used
+         * - $extra_params - array of additional params with the following array keys:
+         *      - `content` - [int] profile content id
+         *      - `module` - [string] module name
+         * @hook @ref hook-profile-added
+         */
+        bx_alert('profile', 'edit', $oProfile->id(), 0, [
+            'content' => $iContentId, 
+            'module' => $sModule
+        ]);
+    }
+
+    public function getProfileByCurrentUrl ()
+    {
+        $iProfileId = bx_process_input(bx_get('profile_id'), BX_DATA_INT);
+        
+        if ($iProfileId)
+            return  BxDolProfile::getInstance($iProfileId);
+
+        $iContentId = bx_process_input(bx_get('id'), BX_DATA_INT);
+        if ($iContentId)
+            return BxDolProfile::getInstanceByContentAndType($iContentId, $this->getName());
+        
+        return false;
+    }
+    
+    public function getEntryImageData($aContentInfo, $sField = 'FIELD_PICTURE', $aTranscoders = array())
+    {
+        return parent::getEntryImageData($aContentInfo, $sField, $aTranscoders);
+    }
+    
+    public function getProfileName ($aContentInfo)
+    {
+        return bx_process_output($aContentInfo[$this->_oConfig->CNF['FIELD_NAME']]);
+    }
+    
+    public function getMenuItemTitleByConnection($sConnection, $sAction, $iContentProfileId, $iInitiatorProfileId = 0)
+    {
+        $oConnection = BxDolConnection::getObjectInstance($sConnection);
+        if(!$oConnection)
+            return false;
+        
+        if(!$iInitiatorProfileId)
+            $iInitiatorProfileId = bx_get_logged_profile_id();
+
+        $aResult = [];
+        switch($sConnection) {
+            case 'sys_profiles_friends':
+                $aResult = $this->_getMenuItemTitleProfilesFriends($sAction, $iContentProfileId, $iInitiatorProfileId, $oConnection);
+                break;
+
+            case 'sys_profiles_subscriptions':
+                $aResult = $this->_getMenuItemTitleProfilesSubscriptions($sAction, $iContentProfileId, $iInitiatorProfileId, $oConnection);
+                break;
+        }
+
+        return $aResult;
+    }
+
+    protected function _getMenuItemTitleProfilesFriends($sAction, $iContentProfileId, $iInitiatorProfileId, &$oConnection)
+    {
+        $CNF = $this->_oConfig->getCNF();
+
+        $aResult = [];
+        if($oConnection->isConnectedNotMutual($iInitiatorProfileId, $iContentProfileId))
+            $aResult = [
+                'add' => _t(!empty($CNF['T']['menu_item_title_befriend_sent']) ? $CNF['T']['menu_item_title_befriend_sent'] : '_sys_menu_item_title_sm_befriend_sent'),
+                'remove' => _t(!empty($CNF['T']['menu_item_title_unfriend_cancel']) ? $CNF['T']['menu_item_title_unfriend_cancel'] : '_sys_menu_item_title_sm_unfriend_cancel'),
+            ];
+        else if($oConnection->isConnectedNotMutual($iContentProfileId, $iInitiatorProfileId))
+            $aResult = [
+                'add' => _t(!empty($CNF['T']['menu_item_title_befriend_confirm']) ? $CNF['T']['menu_item_title_befriend_confirm'] : '_sys_menu_item_title_sm_befriend_confirm'),
+                'remove' => _t(!empty($CNF['T']['menu_item_title_unfriend_reject']) ? $CNF['T']['menu_item_title_unfriend_reject'] : '_sys_menu_item_title_sm_unfriend_reject'),
+            ];
+        else if($oConnection->isConnected($iInitiatorProfileId, $iContentProfileId, true))
+            $aResult = [
+                'add' => '',
+                'remove' => _t(!empty($CNF['T']['menu_item_title_unfriend']) ? $CNF['T']['menu_item_title_unfriend'] : '_sys_menu_item_title_sm_unfriend'),
+            ];
+        else
+            $aResult = [
+                'add' => _t(!empty($CNF['T']['menu_item_title_befriend']) ? $CNF['T']['menu_item_title_befriend'] : '_sys_menu_item_title_sm_befriend'),
+                'remove' => '',
+            ];
+
+        return !empty($sAction) && isset($aResult[$sAction]) ? $aResult[$sAction] : $aResult;
+    }
+
+    protected function _getMenuItemTitleProfilesSubscriptions($sAction, $iContentProfileId, $iInitiatorProfileId, &$oConnection)
+    {
+        $CNF = $this->_oConfig->getCNF();
+
+        $aResult = [];
+        if($oConnection->isConnected($iInitiatorProfileId, $iContentProfileId))
+            $aResult = [
+                'add' => '',
+                'remove' => _t(!empty($CNF['T']['menu_item_title_unsubscribe']) ? $CNF['T']['menu_item_title_unsubscribe'] : '_sys_menu_item_title_sm_unsubscribe'),
+            ];
+        else
+            $aResult = [
+                'add' => _t(!empty($CNF['T']['menu_item_title_subscribe']) ? $CNF['T']['menu_item_title_subscribe'] : '_sys_menu_item_title_sm_subscribe'),
+                'remove' => '',
+            ];
+
+        return !empty($sAction) && !empty($aResult[$sAction]) ? $aResult[$sAction] : $aResult;
+    }
+    
+    public function decodeDataAPI($aData, $aParams = [])
+    {
+        $CNF = $this->_oConfig->CNF;
+
+        $sModule = $this->getName();
+        $iId = (int)$aData[$CNF['FIELD_ID']];
+        $oProfile = BxDolProfile::getInstanceByContentAndType($iId, $sModule);
+
+        $aResult = [
+            'id' => $iId, 
+            'module' => $sModule,
+            'module_title' => _t($CNF['T']['txt_sample_single']),
+            'added' => $aData[$CNF['FIELD_ADDED']],
+            'author' => $aData[$CNF['FIELD_AUTHOR']],
+            'author_data' => $oProfile !== false ? BxDolProfile::getData($oProfile->id()) : '',
+            'title' => $aData[$CNF['FIELD_TITLE']],
+            $CNF['FIELD_TITLE'] => $aData[$CNF['FIELD_TITLE']],
+            'url' => bx_api_get_relative_url($this->serviceGetLink($iId)),
+            'image' => $this->serviceGetThumb($iId),
+            'cover' => $this->serviceGetCover($iId),
+        ];
+
+        if(isset($aParams['template']) && $aParams['template'] == 'unit_wo_info')
+            return $aResult;
+
+        if(isset($aParams['extended']) && $aParams['extended'] === true)
+            $aResult['text'] = $aData[$CNF['FIELD_TEXT']];
+
+        if(getParam('sys_api_conn_in_prof_units') == 'on' && $oProfile !== false) {
+            $iProfileId = $oProfile->id();
+            $aResult['title'] = $oProfile->getDisplayName();
+            $aResult['online'] = $oProfile->isOnline();
+            if(($oConnection = BxDolConnection::getObjectInstance('sys_profiles_friends')) !== false && isset($CNF['URI_VIEW_FRIENDS'])) {
+                $aResult['friends_count'] = $oConnection->getConnectedContentCount($iProfileId, true);
+                $aResult['friends_list'] = $oConnection->getConnectedListAPI($iProfileId, true, BX_CONNECTIONS_CONTENT_TYPE_CONTENT);
+                if(isLogged()) {
+                    $iLoggedId = bx_get_logged_profile_id();
+                    $aResult['mutual_friends_count'] = $oConnection->getCommonContentCount($iProfileId, $iLoggedId, true);
+                    $aResult['mutual_friends_list'] = $oConnection->getCommonListAPI($iProfileId, $iLoggedId, true);
+                }
+            }
+
+            if(($oConnection = BxDolConnection::getObjectInstance('sys_profiles_subscriptions')) !== false){
+                $aResult['followers_count'] = $oConnection->getConnectedInitiatorsCount($iProfileId);
+                $aResult['followers_list'] = $oConnection->getConnectedListAPI($iProfileId, false, BX_CONNECTIONS_CONTENT_TYPE_INITIATORS);
+            }
+        }
+
+        $sKey = 'OBJECT_MENU_SNIPPET_META';
+        if(!empty($CNF[$sKey]) && ($oMetaMenu = BxDolMenu::getObjectInstance($CNF[$sKey], $this->_oTemplate)) !== false) {
+            $oPrivacy = BxDolPrivacy::getObjectInstance($CNF['OBJECT_PRIVACY_VIEW']);
+            $bPublic = !$oPrivacy || $oPrivacy->check($iId) || $oPrivacy->isPartiallyVisible($aData[$CNF['FIELD_ALLOW_VIEW_TO']]);
+
+            $oMetaMenu->setContentId($iId);
+            $oMetaMenu->setContentPublic($bPublic);
+            if(isset($aParams['context']))
+                $oMetaMenu->setContext($aParams['context']);
+
+            $aResult['meta'] = $oMetaMenu->getCodeAPI();
+        }
+
+        return $aResult;
+    }
+
+
+    // ====== PROTECTED METHODS
+    protected function _checkAllowedConnect (&$aDataEntry, $isPerformAction, $mixedConnection, $isMutual, $isInvertResult, $isSwap = false)
+    {
+        if (!$this->_iProfileId)
+            return _t('_sys_txt_access_denied');
+
+        $CNF = &$this->_oConfig->CNF;
+
+        $oProfile = BxDolProfile::getInstanceByContentAndType($aDataEntry[$CNF['FIELD_ID']], $this->_aModule['name']);
+        if (!$oProfile || $oProfile->id() == $this->_iProfileId)
+            return _t('_sys_txt_access_denied');
+
+        $sConnObject = '';
+        $sConnMethod = 'checkAllowedConnect';
+        if(is_array($mixedConnection))
+            list($sConnObject, $sConnMethod) = $mixedConnection;
+        else
+            $sConnObject = $mixedConnection;
+
+        return BxDolConnection::getObjectInstance($sConnObject)->$sConnMethod($this->_iProfileId, $oProfile->id(), $isPerformAction, $isMutual, $isInvertResult, $isSwap);
+    }
+
+    protected function _buildRssParams($sMode, $aArgs)
+    {
+        $aParams = array ();
+        $sMode = bx_process_input($sMode);
+        switch ($sMode) {
+            case 'connections':
+                $aParams = array(
+                    'object' => isset($aArgs[0]) ? $aArgs[0] : '',
+                    'type' => isset($aArgs[1]) ? $aArgs[1] : '',
+                    'profile' => isset($aArgs[2]) ? (int)$aArgs[2] : 0,
+                    'mutual' => isset($aArgs[3]) ? (int)$aArgs[3] : 0,
+                    'profile2' => isset($aArgs[4]) ? (int)$aArgs[4] : 0,
+                );
+                break;
+        }
+
+        return $aParams;
+    }
+
+    protected function _serviceGetTimelineProfileImage($aEvent, $aBrowseParams, $aBuildParams)
+    {
+        $CNF = &$this->_oConfig->CNF;
+
+        $aFileInfo = BxDolStorage::getObjectInstance($CNF[$aBuildParams['stg']])->getFile((int)$aEvent['object_id']);
+        if(empty($aFileInfo) || !is_array($aFileInfo))
+            return false;
+            
+        $aEventContent = unserialize($aEvent['content']);
+        if(!is_array($aEventContent) || empty($aEventContent['content']))
+            return false;
+
+        $aContentInfo = $this->_oDb->getContentInfoById($aEventContent['content']);
+        if(empty($aContentInfo) || !is_array($aContentInfo))
+            return false;
+
+        $oProfile = BxDolProfile::getInstanceMagic($aContentInfo['profile_id']);
+        if (!$oProfile->isActive())
+            return false;
+            
+        $sUserName = $oProfile->getDisplayName();
+
+        $sSample = isset($CNF['T'][$aBuildParams['txt_sswa']]) ? $CNF['T'][$aBuildParams['txt_sswa']] : $CNF['T'][$aBuildParams['txt_ss']];
+
+        //--- Title & Description
+        $sTitle = !empty($aContentInfo[$CNF['FIELD_TITLE']]) ? $aContentInfo[$CNF['FIELD_TITLE']] : '';
+        if(empty($sTitle) && !empty($aContentInfo[$CNF['FIELD_TEXT']]))
+            $sTitle = $aContentInfo[$CNF['FIELD_TEXT']];
+
+        $sDescription = _t($CNF['T'][$aBuildParams['txt_sau']], $sUserName, _t($sSample));
+
+        return array(
+            'owner_id' => $aEvent['owner_id'],
+            'object_owner_id' => $aContentInfo['profile_id'],
+            'icon' => !empty($CNF['ICON']) ? $CNF['ICON'] : '',
+            'sample' => $sSample,
+            'sample_wo_article' => $CNF['T'][$aBuildParams['txt_ss']],
+            'sample_action' => isset($CNF['T'][$aBuildParams['txt_sa']]) ? $CNF['T'][$aBuildParams['txt_sa']] : '',
+            'url' => bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $CNF['URI_VIEW_ENTRY'] . '&id=' . $aContentInfo[$CNF['FIELD_ID']])),
+            'content' => $this->_getContentForTimelineProfileImage($aEvent, $aBrowseParams, $aBuildParams, $aContentInfo, $aFileInfo), //a string to display or array to parse default template before displaying.
+            'date' => $aContentInfo[$CNF['FIELD_ADDED']],
+            'views' => '',
+            'votes' => '',
+            'scores' => '',
+            'reports' => '',
+            'comments' => '',
+            'title' => $sTitle, //may be empty.
+            'description' => $sDescription //may be empty.
+        );
+    }
+
+    protected function _serviceGetTimelineProfileImageAllowedView($aEvent)
+    {
+        $sError = _t('_sys_access_denied_to_private_content');
+
+        if(empty($aEvent['content']) || !is_array($aEvent['content']) || empty($aEvent['content']['id']))
+            return $sError;
+
+        $aContentInfo = $this->_oDb->getContentInfoById($aEvent['content']['id']);
+        if(empty($aContentInfo) || !is_array($aContentInfo))
+            return $sError;
+
+        return $this->serviceCheckAllowedViewForProfile($aContentInfo);
+    }
+
+    protected function _getContentForTimelineProfileImage($aEvent, $aBrowseParams, $aBuildParams, $aContentInfo, $aFileInfo)
+    {
+    	$CNF = &$this->_oConfig->CNF;
+
+    	$sUrl = bx_absolute_url(BxDolPermalinks::getInstance()->permalink('page.php?i=' . $CNF['URI_VIEW_ENTRY'] . '&id=' . $aContentInfo[$CNF['FIELD_ID']]));
+
+    	//--- Image(s)
+        $sImage = $this->_oConfig->getImageUrl($aFileInfo['id'], $aBuildParams['trans']);
+        $sImageOrig = $this->_oConfig->getImageUrl($aFileInfo['id'], $aBuildParams['trans_orig']);
+        if(!empty($sImage)) {
+            if(empty($sImageOrig))
+                $sImageOrig = $sImage;
+
+            $a = ['id' => $aFileInfo['id'], 'url' => $sUrl, 'src' => $sImage];
+            if(CHECK_ACTION_RESULT_ALLOWED === $this->checkAllowedView($aContentInfo)) {
+                $a['src_orig'] = $sImageOrig;
+
+                $sCnfKey = 'OBJECT_IMAGES_TRANSCODER_GALLERY';
+                if(!empty($CNF[$sCnfKey]) && ($sSrcMedium = $this->_oConfig->getImageUrl($aFileInfo['id'], [$sCnfKey])) != '')
+                    $a['src_medium'] = $sSrcMedium;
+            }
+            $aImages = [$a];
+        }
+
+    	return array(
+            'sample' => isset($CNF['T'][$aBuildParams['txt_sswa']]) ? $CNF['T'][$aBuildParams['txt_sswa']] : $CNF['T'][$aBuildParams['txt_ss']],
+            'sample_wo_article' => $CNF['T'][$aBuildParams['txt_ss']],
+            'sample_action' => isset($CNF['T'][$aBuildParams['txt_sa']]) ? $CNF['T'][$aBuildParams['txt_sa']] : '',
+            'id' => $aContentInfo[$CNF['FIELD_ID']],
+            'url' => $sUrl,
+            'title' =>  '',
+            'text' => '',
+            'images' => $aImages,
+            'videos' => array()
+        );
+    }
+
+    protected function _entityComments($sObject, $iId = 0)
+    {
+        if(!$iId)
+            $iId = bx_process_input(bx_get('id'), BX_DATA_INT);
+
+        if(!$iId)
+            $iId = bx_process_input(bx_get('profile_id'), BX_DATA_INT);
+
+        if(!$iId)
+            return false;
+
+        $oCmts = BxDolCmts::getObjectInstance($sObject, $iId);
+        if(!$oCmts || !$oCmts->isEnabled())
+            return false;
+
+        if (bx_is_api())
+            return [bx_srv('system', 'get_data_api', [['module' => $sObject, 'object_id' => $iId]], 'TemplCmtsServices')];
+
+        return $oCmts->getCommentsBlock(array(), array('in_designbox' => false));
+    }
+
+    protected function _getContent($iContentId = 0, $sFuncGetContent = 'getContentInfoById')
+    {
+        if(!$iContentId && bx_get('id') === false && bx_get('profile_id') !== false) {
+            $oProfile = BxDolProfile::getInstance((int)bx_get('profile_id'));
+            if($oProfile)
+                $iContentId = $oProfile->getContentId();
+        }
+
+        return parent::_getContent($iContentId, $sFuncGetContent);
+    }
+}
+
+/** @} */

+ 242 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/AbstractTest.php

@@ -0,0 +1,242 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\MiniTest\Test;
+
+use Akeeba\S3\Connector;
+use RuntimeException;
+
+abstract class AbstractTest
+{
+	const TEN_KB = 10240;
+
+	const HUNDRED_KB = 102400;
+
+	const SIX_HUNDRED_KB = 614400;
+
+	const ONE_MB = 1048576;
+
+	const FIVE_MB = 5242880;
+
+	const SIX_MB = 6291456;
+
+	const TEN_MB = 10485760;
+
+	const ELEVEN_MB = 11534336;
+
+	const BLOCK_SIZE = 1048576;
+
+	const FILE_HASHING_ALGORITHM = 'sha256';
+
+	public static function setup(Connector $s3, array $options): void
+	{
+		// Runs before any test
+	}
+
+	public static function teardown(Connector $s3, array $options): void
+	{
+		// Runs after all tests are finished
+	}
+
+	/**
+	 * Creates a file with random data and returns its file path.
+	 *
+	 * The random data in the file repeats every $blockSize bytes when $reuseBlock is true.
+	 *
+	 * @param   int   $size  Size in files
+	 *
+	 * @param   int   $blockSize
+	 * @param   bool  $reuseBlock
+	 *
+	 * @return  string  The full path to the temporary file.
+	 */
+	protected static function createFile(int $size = AbstractTest::SIX_HUNDRED_KB, int $blockSize = self::BLOCK_SIZE, bool $reuseBlock = true)
+	{
+		$tempFilePath = tempnam(static::getTempFolder(), 'as3');
+
+		if ($tempFilePath === false)
+		{
+			throw new RuntimeException("Cannot create a temporary file.");
+		}
+
+		$fp = @fopen($tempFilePath, 'w', false);
+
+		if ($fp === false)
+		{
+			throw new RuntimeException("Cannot write to the temporary file.");
+		}
+
+		$blockSize     = static::BLOCK_SIZE;
+		$lastBlockSize = $size % $blockSize;
+		$wholeBlocks   = (int) (($size - $lastBlockSize) / $blockSize);
+		$blockData     = static::getRandomData();
+
+		for ($i = 0; $i < $wholeBlocks; $i++)
+		{
+			fwrite($fp, $blockData);
+
+			if (!$reuseBlock)
+			{
+				$blockData = static::getRandomData($blockSize);
+			}
+		}
+
+		if ($lastBlockSize > 0)
+		{
+			fwrite($fp, $blockData, $lastBlockSize);
+		}
+
+
+		fclose($fp);
+
+		return $tempFilePath;
+	}
+
+	/**
+	 * Get a writeable temporary folder
+	 *
+	 * @return  string
+	 */
+	protected static function getTempFolder(): string
+	{
+		$tempPath = sys_get_temp_dir();
+
+		if (!is_writable($tempPath))
+		{
+			$tempPath = __DIR__ . '/tmp';
+
+			if (!is_dir($tempPath))
+			{
+				@mkdir($tempPath, 0755, true);
+			}
+		}
+
+		if (!is_writable($tempPath))
+		{
+			throw new RuntimeException("Cannot get a writeable temporary path.");
+		}
+
+		return $tempPath;
+	}
+
+	/**
+	 * Checks that two files are of equal length and contents
+	 *
+	 * @param   string  $referenceFilePath  The known, reference file
+	 * @param   string  $unknownFilePath    The file we want to verify is the same as the reference file
+	 *
+	 * @return  bool
+	 */
+	protected static function areFilesEqual(string $referenceFilePath, string $unknownFilePath): bool
+	{
+		if (!file_exists($referenceFilePath) || !file_exists($unknownFilePath))
+		{
+			return false;
+		}
+
+		if (!is_file($referenceFilePath) || !is_file($unknownFilePath))
+		{
+			return false;
+		}
+
+		if (!is_readable($referenceFilePath) || !is_readable($unknownFilePath))
+		{
+			return false;
+		}
+
+		if (@filesize($referenceFilePath) !== @filesize($unknownFilePath))
+		{
+			return false;
+		}
+
+		return hash_file(static::FILE_HASHING_ALGORITHM, $referenceFilePath) === hash_file(static::FILE_HASHING_ALGORITHM, $unknownFilePath);
+	}
+
+	/**
+	 * Checks that two strings are of equal length and contents
+	 *
+	 * @param   string  $referenceString  The known, reference file
+	 * @param   string  $unknownString    The file we want to verify is the same as the reference file
+	 *
+	 * @return  bool
+	 */
+	protected static function areStringsEqual(string $referenceString, string $unknownString): bool
+	{
+		return $referenceString === $unknownString;
+	}
+
+	/**
+	 * Returns random data of the specific size in bytes
+	 *
+	 * @param   int  $length  How many bytes of random data to return
+	 *
+	 * @return  string  Your random data
+	 */
+	protected static function getRandomData(int $length = self::BLOCK_SIZE): string
+	{
+		$blockData = '';
+
+		if (substr(strtolower(PHP_OS), 0, 7) !== 'windows')
+		{
+			$fpRandom = @fopen('/dev/urandom', 'r');
+			if ($fpRandom !== false)
+			{
+				$blockData = @fread($fpRandom, $length);
+				@fclose($fpRandom);
+			}
+		}
+
+		if (empty($blockData) && function_exists('random_bytes'))
+		{
+			try
+			{
+				$blockData = random_bytes($length);
+			}
+			catch (\Exception $e)
+			{
+				$blockData = '';
+			}
+		}
+
+		if (empty($blockData) && function_exists('openssl_random_pseudo_bytes'))
+		{
+			$blockData = openssl_random_pseudo_bytes($length);
+		}
+
+		if (empty($blockData) && function_exists('mcrypt_create_iv'))
+		{
+			$blockData = mcrypt_create_iv($length, MCRYPT_DEV_URANDOM);
+
+			if (empty($blockData))
+			{
+				$blockData = mcrypt_create_iv($length, MCRYPT_RAND);
+			}
+		}
+
+		if (empty($blockData))
+		{
+			for ($i = 0; $i < $length; $i++)
+			{
+				$blockData .= ord(mt_rand(0, 255));
+			}
+		}
+
+		return $blockData;
+	}
+
+	protected static function assert(bool $condition, string $message): void
+	{
+		if ($condition)
+		{
+			return;
+		}
+
+		throw new RuntimeException($message);
+	}
+}

+ 217 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/BigFiles.php

@@ -0,0 +1,217 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\MiniTest\Test;
+
+
+use Akeeba\S3\Connector;
+use Akeeba\S3\Input;
+
+/**
+ * Upload, download and delete big files (over 1MB), without multipart uploads. Uses string or file sources.
+ *
+ * @package Akeeba\MiniTest\Test
+ */
+class BigFiles extends AbstractTest
+{
+	/**
+	 * Should I download the file after uploading it to test for contents consistency?
+	 *
+	 * @var bool
+	 */
+	protected static $downloadAfter = true;
+
+	/**
+	 * Should I delete the uploaded file after the test case is done?
+	 *
+	 * @var bool
+	 */
+	protected static $deleteRemote = true;
+
+	/**
+	 * Should I use multipart (chunked) uploads?
+	 *
+	 * @var bool
+	 */
+	protected static $multipart = false;
+
+	/**
+	 * Chunk size for each multipart upload. Must be at least 5MB or the library overrides us.
+	 *
+	 * @var int
+	 */
+	protected static $uploadChunkSize = 5242880;
+
+	/**
+	 * Number of uploaded chunks.
+	 *
+	 * This is set by static::upload(). Zero for single part uploads, non-zero for multipart uploads.
+	 *
+	 * @var int
+	 */
+	protected static $numberOfChunks = 0;
+
+	public static function upload5MBString(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, static::FIVE_MB, 'bigtest_5mb.dat');
+	}
+
+	public static function upload6MBString(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, static::SIX_MB, 'bigtest_6mb.dat');
+	}
+
+	public static function upload10MBString(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, static::TEN_MB, 'bigtest_10mb.dat');
+	}
+
+	public static function upload11MBString(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, static::ELEVEN_MB, 'bigtest_11mb.dat');
+	}
+
+	public static function upload5MBFile(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, static::FIVE_MB, 'bigtest_5mb.dat', false);
+	}
+
+	public static function upload6MBFile(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, static::SIX_MB, 'bigtest_6mb.dat', false);
+	}
+
+	public static function upload10MBFile(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, static::TEN_MB, 'bigtest_10mb.dat', false);
+	}
+
+	public static function upload11MBFile(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, static::ELEVEN_MB, 'bigtest_11mb.dat', false);
+	}
+
+	protected static function upload(Connector $s3, array $options, int $size, string $uri, bool $useString = true): bool
+	{
+		// Randomize the name. Required for archive buckets where you cannot overwrite data.
+		$dotPos = strrpos($uri, '.');
+		$uri    = substr($uri, 0, $dotPos) . '.' . hash('md5', microtime(false)) . substr($uri, $dotPos);
+
+		static::$numberOfChunks = 0;
+
+		if ($useString)
+		{
+			$sourceData = static::getRandomData($size);
+			$input      = Input::createFromData($sourceData);
+		}
+		else
+		{
+			// Create a file with random data
+			$sourceFile = static::createFile($size);
+			$input      = Input::createFromFile($sourceFile);
+		}
+
+		// Upload the file. Throws exception if it fails.
+		$bucket = $options['bucket'];
+
+		if (!static::$multipart)
+		{
+			$s3->putObject($input, $bucket, $uri);
+		}
+		else
+		{
+			// Get an upload session
+			$uploadSession = $s3->startMultipart($input, $bucket, $uri);
+
+			// This array holds the etags of uploaded parts. Used by finalizeMultipart.
+			$eTags      = [];
+			$partNumber = 1;
+
+			while (true)
+			{
+				// We need to create a new input for each upload chunk
+				if ($useString)
+				{
+					$input = Input::createFromData($sourceData);
+				}
+				else
+				{
+					$input = Input::createFromFile($sourceFile);
+				}
+
+				$input->setUploadID($uploadSession);
+				$input->setEtags($eTags);
+				$input->setPartNumber($partNumber);
+
+				$etag = $s3->uploadMultipart($input, $bucket, $uri, [], static::$uploadChunkSize);
+
+				// If the result was null we have no more file parts to process.
+				if (is_null($etag))
+				{
+					break;
+				}
+
+				// Append the etag to the etags array
+				$eTags[] = $etag;
+
+				// Set the etags array in the Input object (required by finalizeMultipart)
+				$input->setEtags($eTags);
+
+				$partNumber++;
+			}
+
+			static::$numberOfChunks = count($eTags);
+
+			// Finalize the multipart upload. Tells Amazon to construct the file from the uploaded parts.
+			$s3->finalizeMultipart($input, $bucket, $uri);
+		}
+
+		// Tentatively accept that this method succeeded.
+		$result = true;
+
+		// Should I download the file and compare its contents?
+		if (static::$downloadAfter)
+		{
+			if ($useString)
+			{
+				// Download the data. Throws exception if it fails.
+				$downloadedData = $s3->getObject($bucket, $uri);
+
+				// Compare the file contents.
+				$result = static::areStringsEqual($sourceData, $downloadedData);
+			}
+			else
+			{
+				// Download the data. Throws exception if it fails.
+				$downloadedFile = tempnam(static::getTempFolder(), 'as3');
+				$s3->getObject($bucket, $uri, $downloadedFile);
+
+				// Compare the file contents.
+				$result = static::areFilesEqual($sourceFile, $downloadedFile);
+
+				@unlink($downloadedFile);
+			}
+		}
+
+		// Remove the local files
+		if (!$useString)
+		{
+			@unlink($sourceFile);
+		}
+
+		// Should I delete the remotely stored file?
+		if (static::$deleteRemote)
+		{
+			// Delete the remote file. Throws exception if it fails.
+			$s3->deleteObject($bucket, $uri);
+		}
+
+		return $result;
+	}
+}

+ 25 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/BucketLocation.php

@@ -0,0 +1,25 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\MiniTest\Test;
+
+
+use Akeeba\S3\Connector;
+
+class BucketLocation extends AbstractTest
+{
+	public static function getBucketLocation(Connector $s3, array $options): bool
+	{
+		$location = $s3->getBucketLocation($options['bucket']);
+
+		static::assert($location === $options['region'], "Bucket ‘{$options['bucket']}′ reports being in region ‘{$location}′ instead of expected ‘{$options['region']}′");
+
+		return true;
+	}
+}

+ 52 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/BucketsList.php

@@ -0,0 +1,52 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\MiniTest\Test;
+
+
+use Akeeba\S3\Connector;
+use RuntimeException;
+
+class BucketsList extends AbstractTest
+{
+	public static function listBucketsDetailed(Connector $s3, array $options): bool
+	{
+		$buckets = $s3->listBuckets(true);
+
+		static::assert(is_array($buckets), "Detailed buckets list is not an array");
+		static::assert(isset($buckets['owner']), "Detailed buckets list does not list an owner");
+		static::assert(isset($buckets['owner']['id']), "Detailed buckets list does not list an owner's id");
+		static::assert(isset($buckets['owner']['name']), "Detailed buckets list does not list an owner's name");
+		static::assert(isset($buckets['buckets']), "Detailed buckets list does not list any buckets");
+
+		foreach ($buckets['buckets'] as $bucketInfo)
+		{
+			static::assert(isset($bucketInfo['name']), "Bucket information does not list a name");
+			static::assert(isset($bucketInfo['time']), "Bucket information does not list a created times");
+
+			if ($bucketInfo['name'] === $options['bucket'])
+			{
+				return true;
+			}
+		}
+
+		throw new RuntimeException("Detailed buckets list does not include configured bucket ‘{$options['bucket']}′");
+	}
+
+	public static function listBucketsSimple(Connector $s3, array $options): bool
+	{
+		$buckets = $s3->listBuckets(false);
+
+		static::assert(is_array($buckets), "Simple buckets list is not an array");
+		static::assert(in_array($options['bucket'], $buckets), "Simple buckets list does not include configured bucket ‘{$options['bucket']}′");
+
+		return true;
+	}
+
+}

+ 67 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/HeadObject.php

@@ -0,0 +1,67 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+
+namespace Akeeba\MiniTest\Test;
+
+
+use Akeeba\S3\Connector;
+use Akeeba\S3\Exception\CannotDeleteFile;
+use Akeeba\S3\Exception\CannotGetFile;
+use Akeeba\S3\Input;
+
+class HeadObject extends AbstractTest
+{
+	public static function testExistingFile(Connector $s3, array $options): bool
+	{
+		$uri = 'head_test.dat';
+
+		// Randomize the name. Required for archive buckets where you cannot overwrite data.
+		$dotPos = strrpos($uri, '.');
+		$uri    = substr($uri, 0, $dotPos) . '.' . hash('md5', microtime(false)) . substr($uri, $dotPos);
+
+		// Create a file with random data
+		$sourceFile = static::createFile(AbstractTest::TEN_KB);
+
+		// Upload the file. Throws exception if it fails.
+		$bucket = $options['bucket'];
+		$input  = Input::createFromFile($sourceFile);
+
+		$s3->putObject($input, $bucket, $uri);
+
+		$headers = $s3->headObject($bucket, $uri);
+
+		static::assert(isset($headers['size']), 'The returned headers do not contain the object size');
+		static::assert($headers['size'] == AbstractTest::TEN_KB, 'The returned size does not match');
+
+		// Remove the local files
+		@unlink($sourceFile);
+
+		// Delete the remote file. Throws exception if it fails.
+		$s3->deleteObject($bucket, $uri);
+
+		return true;
+	}
+
+	public static function testMissingFile(Connector $s3, array $options): bool
+	{
+		$bucket = $options['bucket'];
+
+		try
+		{
+			$headers = $s3->headObject($bucket, hash('md5', microtime(false)) . '_does_not_exist');
+		}
+		catch (CannotGetFile $e)
+		{
+			return true;
+		}
+
+		return false;
+	}
+}

+ 308 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/ListFiles.php

@@ -0,0 +1,308 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\MiniTest\Test;
+
+
+use Akeeba\S3\Connector;
+use Akeeba\S3\Exception\CannotPutFile;
+use Akeeba\S3\Input;
+
+class ListFiles extends AbstractTest
+{
+	private static $paths = [
+		'listtest_one.dat',
+		'listtest_two.dat',
+		'listtest_three.dat',
+		'list_deeper/test_one.dat',
+		'list_deeper/test_two.dat',
+		'list_deeper/test_three.dat',
+		'list_deeper/listtest_four.dat',
+		'list_deeper/listtest_five.dat',
+		'list_deeper/listtest_six.dat',
+		'list_deeper/spam.dat',
+		'list_deeper/listtest_deeper/seven.dat',
+		'list_deeper/listtest_deeper/eight.dat',
+		'spam.dat',
+	];
+
+	public static function setup(Connector $s3, array $options): void
+	{
+		$data = static::getRandomData(static::TEN_KB);
+
+		foreach (static::$paths as $uri)
+		{
+			$input = Input::createFromData($data);
+			try
+			{
+				$s3->putObject($input, $options['bucket'], $uri);
+			}
+			catch (CannotPutFile $e)
+			{
+				// Expected for archival buckets
+			}
+		}
+	}
+
+	public static function teardown(Connector $s3, array $options): void
+	{
+		foreach (static::$paths as $uri)
+		{
+			try
+			{
+				$s3->deleteObject($options['bucket'], $uri);
+			}
+			catch (\Exception $e)
+			{
+				// No problem if I can't delete the file
+			}
+		}
+	}
+
+	public static function testGetAll(Connector $s3, array $options): bool
+	{
+		$listing = $s3->getBucket($options['bucket'], 'listtest_');
+
+		static::assert(is_array($listing), "The files listing must be an array");
+		static::assert(count($listing) == 3, "I am expecting to see 3 files");
+
+		// Make sure I have the expected files
+		static::assert(array_key_exists('listtest_one.dat', $listing), "File listtest_one.dat not in listing");
+		static::assert(array_key_exists('listtest_two.dat', $listing), "File listtest_two.dat not in listing");
+		static::assert(array_key_exists('listtest_three.dat', $listing), "File listtest_three.dat not in listing");
+
+		// I must not see the files in subdirectories
+		static::assert(!array_key_exists('listtest_four.dat', $listing), "File listtest_four.dat in listing");
+		static::assert(!array_key_exists('listtest_five.dat', $listing), "File listtest_five.dat in listing");
+		static::assert(!array_key_exists('listtest_six.dat', $listing), "File listtest_six.dat in listing");
+
+		// I must not see the files not matching the prefix I gave
+		static::assert(!array_key_exists('spam.dat', $listing), "File spam.dat in listing");
+		static::assert(!array_key_exists('ham.dat', $listing), "File ham.dat in listing");
+
+		foreach ($listing as $fileName => $info)
+		{
+			static::assert(isset($info['name']), "File entries must have a name");
+			static::assert(isset($info['time']), "File entries must have a time");
+			static::assert(isset($info['size']), "File entries must have a size");
+			static::assert(isset($info['hash']), "File entries must have a hash");
+		}
+
+		return true;
+	}
+
+	public static function testGetContinue(Connector $s3, array $options): bool
+	{
+		$listing = $s3->getBucket($options['bucket'], 'listtest_', null, 1);
+
+		static::assert(is_array($listing), "The files listing must be an array");
+		static::assert(count($listing) == 1, sprintf("I am expecting to see 1 file, %s seen", count($listing)));
+
+		$files     = array_keys($listing);
+		$continued = $s3->getBucket($options['bucket'], 'listtest_', array_shift($files));
+
+		static::assert(is_array($continued), "The continued files listing must be an array");
+		static::assert(count($continued) == 2, sprintf("I am expecting to see 2 files, %s seen", count($continued)));
+
+		$listing = array_merge($listing, $continued);
+
+		// Make sure I have the expected files
+		static::assert(array_key_exists('listtest_one.dat', $listing), "File listtest_one.dat not in listing");
+		static::assert(array_key_exists('listtest_two.dat', $listing), "File listtest_two.dat not in listing");
+		static::assert(array_key_exists('listtest_three.dat', $listing), "File listtest_three.dat not in listing");
+
+		// I must not see the files in subdirectories
+		static::assert(!array_key_exists('listtest_four.dat', $listing), "File listtest_four.dat in listing");
+		static::assert(!array_key_exists('listtest_five.dat', $listing), "File listtest_five.dat in listing");
+		static::assert(!array_key_exists('listtest_six.dat', $listing), "File listtest_six.dat in listing");
+
+		// I must not see the files not matching the prefix I gave
+		static::assert(!array_key_exists('spam.dat', $listing), "File spam.dat in listing");
+		static::assert(!array_key_exists('ham.dat', $listing), "File ham.dat in listing");
+
+		foreach ($listing as $fileName => $info)
+		{
+			static::assert(isset($info['name']), "File entries must have a name");
+			static::assert(isset($info['time']), "File entries must have a time");
+			static::assert(isset($info['size']), "File entries must have a size");
+			static::assert(isset($info['hash']), "File entries must have a hash");
+		}
+
+		return true;
+	}
+
+	public static function testGetSubdirectoryFiles(Connector $s3, array $options): bool
+	{
+		$listing = $s3->getBucket($options['bucket'], 'list_deeper/test_');
+
+		static::assert(is_array($listing), "The files listing must be an array");
+		static::assert(count($listing) == 3, "I am expecting to see 3 files");
+
+		// Make sure I have the expected files
+		static::assert(array_key_exists('list_deeper/test_one.dat', $listing), "File test_one.dat not in listing");
+		static::assert(array_key_exists('list_deeper/test_two.dat', $listing), "File test_two.dat not in listing");
+		static::assert(array_key_exists('list_deeper/test_three.dat', $listing), "File test_three.dat not in listing");
+
+		// I must not see the files with different  prefix
+		static::assert(!array_key_exists('list_deeper/listtest_four.dat', $listing), "File listtest_four.dat in listing");
+		static::assert(!array_key_exists('list_deeper/listtest_five.dat', $listing), "File listtest_five.dat in listing");
+		static::assert(!array_key_exists('list_deeper/listtest_six.dat', $listing), "File listtest_six.dat in listing");
+		static::assert(!array_key_exists('list_deeper/spam.dat', $listing), "File spam.dat in listing");
+
+		// I must not see the files in subdirectories
+		static::assert(!array_key_exists('list_deeper/listtest_deeper/seven.dat', $listing), "File spam.dat in listing");
+		static::assert(!array_key_exists('list_deeper/listtest_deeper/eight.dat', $listing), "File spam.dat in listing");
+
+		foreach ($listing as $fileName => $info)
+		{
+			static::assert(isset($info['name']), "File entries must have a name");
+			static::assert(isset($info['time']), "File entries must have a time");
+			static::assert(isset($info['size']), "File entries must have a size");
+			static::assert(isset($info['hash']), "File entries must have a hash");
+		}
+
+		return true;
+	}
+
+	public static function testGetSubdirectoryFilesWithContinue(Connector $s3, array $options): bool
+	{
+		$listing = $s3->getBucket($options['bucket'], 'list_deeper/test_', null, 1);
+
+		static::assert(is_array($listing), "The files listing must be an array");
+		static::assert(count($listing) == 1, sprintf("I am expecting to see 1 file, %s seen", count($listing)));
+
+		$files     = array_keys($listing);
+		$continued = $s3->getBucket($options['bucket'], 'list_deeper/test_', array_shift($files));
+
+		static::assert(is_array($continued), "The continued files listing must be an array");
+		static::assert(count($continued) == 2, sprintf("I am expecting to see 2 files, %s seen", count($continued)));
+
+		$listing = array_merge($listing, $continued);
+
+		static::assert(is_array($listing), "The files listing must be an array");
+		static::assert(count($listing) == 3, "I am expecting to see 3 files");
+
+		// Make sure I have the expected files
+		static::assert(array_key_exists('list_deeper/test_one.dat', $listing), "File test_one.dat not in listing");
+		static::assert(array_key_exists('list_deeper/test_two.dat', $listing), "File test_two.dat not in listing");
+		static::assert(array_key_exists('list_deeper/test_three.dat', $listing), "File test_three.dat not in listing");
+
+		// I must not see the files with different  prefix
+		static::assert(!array_key_exists('list_deeper/listtest_four.dat', $listing), "File listtest_four.dat in listing");
+		static::assert(!array_key_exists('list_deeper/listtest_five.dat', $listing), "File listtest_five.dat in listing");
+		static::assert(!array_key_exists('list_deeper/listtest_six.dat', $listing), "File listtest_six.dat in listing");
+		static::assert(!array_key_exists('list_deeper/spam.dat', $listing), "File spam.dat in listing");
+
+		// I must not see the files in subdirectories
+		static::assert(!array_key_exists('list_deeper/listtest_deeper/seven.dat', $listing), "File spam.dat in listing");
+		static::assert(!array_key_exists('list_deeper/listtest_deeper/eight.dat', $listing), "File spam.dat in listing");
+
+		foreach ($listing as $fileName => $info)
+		{
+			static::assert(isset($info['name']), "File entries must have a name");
+			static::assert(isset($info['time']), "File entries must have a time");
+			static::assert(isset($info['size']), "File entries must have a size");
+			static::assert(isset($info['hash']), "File entries must have a hash");
+		}
+
+		return true;
+	}
+
+	public static function testListWithPrefixSharedWithFolder(Connector $s3, array $options): bool
+	{
+		/**
+		 * The prefix list_deeper/listtest_ matches BOTH keys (files) and common prefixes (folders).
+		 *
+		 * Common prefixes have priority so the first request would return zero files. The Connector catches that
+		 * internally and performs more requests until it has at least as many files as we requeted.
+		 */
+		$listing = $s3->getBucket($options['bucket'], 'list_deeper/listtest_', null, 1);
+
+		static::assert(is_array($listing), "The files listing must be an array");
+		static::assert(count($listing) == 1, sprintf("I am expecting to see 1 files, %s seen", count($listing)));
+
+		$files     = array_keys($listing);
+		$continued = $s3->getBucket($options['bucket'], 'list_deeper/listtest_', array_shift($files));
+
+		static::assert(is_array($continued), "The continued files listing must be an array");
+		static::assert(count($continued) == 2, sprintf("I am expecting to see 2 files, %s seen", count($continued)));
+
+		$listing = array_merge($listing, $continued);
+
+		static::assert(is_array($listing), "The files listing must be an array");
+		static::assert(count($listing) == 3, "I am expecting to see 3 files");
+
+		// Make sure I have the expected files
+		static::assert(array_key_exists('list_deeper/listtest_four.dat', $listing), "File listtest_four.dat not in listing");
+		static::assert(array_key_exists('list_deeper/listtest_five.dat', $listing), "File listtest_five.dat not in listing");
+		static::assert(array_key_exists('list_deeper/listtest_six.dat', $listing), "File listtest_six.dat not in listing");
+
+
+		// I must not see the files with different  prefix
+		static::assert(!array_key_exists('list_deeper/test_one.dat', $listing), "File test_one.dat in listing");
+		static::assert(!array_key_exists('list_deeper/test_two.dat', $listing), "File test_two.dat in listing");
+		static::assert(!array_key_exists('list_deeper/test_three.dat', $listing), "File test_three.dat in listing");
+		static::assert(!array_key_exists('list_deeper/spam.dat', $listing), "File spam.dat in listing");
+
+		// I must not see the files in subdirectories
+		static::assert(!array_key_exists('list_deeper/listtest_deeper/seven.dat', $listing), "File spam.dat in listing");
+		static::assert(!array_key_exists('list_deeper/listtest_deeper/eight.dat', $listing), "File spam.dat in listing");
+
+		foreach ($listing as $fileName => $info)
+		{
+			static::assert(isset($info['name']), "File entries must have a name");
+			static::assert(isset($info['time']), "File entries must have a time");
+			static::assert(isset($info['size']), "File entries must have a size");
+			static::assert(isset($info['hash']), "File entries must have a hash");
+		}
+
+		return true;
+	}
+
+	public static function testCommonPrefixes(Connector $s3, array $options): bool
+	{
+		$listing = $s3->getBucket($options['bucket'], 'list_deeper/listtest_', null, null, '/', true);
+
+		static::assert(is_array($listing), "The files listing must be an array");
+		static::assert(count($listing) == 4, sprintf("I am expecting to see 4 entries, %s entries seen.", count($listing)));
+
+		// Make sure I have the expected files
+		static::assert(array_key_exists('list_deeper/listtest_four.dat', $listing), "File listtest_four.dat not in listing");
+		static::assert(array_key_exists('list_deeper/listtest_five.dat', $listing), "File listtest_five.dat not in listing");
+		static::assert(array_key_exists('list_deeper/listtest_six.dat', $listing), "File listtest_six.dat not in listing");
+		static::assert(array_key_exists('list_deeper/listtest_deeper/', $listing), "Folder listtest_deeper not in listing");
+
+		// I must not see the files in subdirectories
+		static::assert(!array_key_exists('list_deeper/listtest_deeper/seven.dat', $listing), "File seven.dat in listing");
+		static::assert(!array_key_exists('list_deeper/listtest_deeper/eight.dat', $listing), "File eight.dat in listing");
+
+		// I must not see the files with different  prefix
+		static::assert(!array_key_exists('list_deeper/spam.dat', $listing), "File spam.dat in listing");
+		static::assert(!array_key_exists('list_deeper/test_one.dat', $listing), "File test_one.dat not in listing");
+		static::assert(!array_key_exists('list_deeper/test_two.dat', $listing), "File test_two.dat not in listing");
+		static::assert(!array_key_exists('list_deeper/test_three.dat', $listing), "File test_three.dat not in listing");
+
+		foreach ($listing as $fileName => $info)
+		{
+			if (substr($fileName, -1) !== '/')
+			{
+				static::assert(isset($info['name']), "File entries must have a name");
+				static::assert(isset($info['time']), "File entries must have a time");
+				static::assert(isset($info['size']), "File entries must have a size");
+				static::assert(isset($info['hash']), "File entries must have a hash");
+			}
+			else
+			{
+				static::assert(isset($info['prefix']), "Folder entries must return a prefix");
+			}
+		}
+
+		return true;
+	}
+}

+ 111 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/ListThousandsOfFiles.php

@@ -0,0 +1,111 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\MiniTest\Test;
+
+use Akeeba\S3\Connector;
+use Akeeba\S3\Input;
+
+class ListThousandsOfFiles extends AbstractTest
+{
+	private const PATH_PREFIX = 'massive/';
+
+	public static function setup(Connector $s3, array $options): void
+	{
+		if (defined('CREATE_2100_FILES') && CREATE_2100_FILES === false)
+		{
+			return;
+		}
+
+		$data = static::getRandomData(128);
+
+		echo "\nPopulating with 2100 files\n";
+
+		for ($i = 1; $i <= 2100; $i++)
+		{
+			if ($i % 10 === 0)
+			{
+				echo "Uploading from $i...\n";
+			}
+
+			$uri   = sprintf('%stest_%04u.dat', static::PATH_PREFIX, $i);
+			$input = Input::createFromData($data);
+			$s3->putObject($input, $options['bucket'], $uri);
+		}
+	}
+
+	public static function testGetAll(Connector $s3, array $options): bool
+	{
+		$listing = $s3->getBucket($options['bucket'], static::PATH_PREFIX);
+
+		static::assert(is_array($listing), "The files listing must be an array");
+		static::assert(count($listing) === 2100, "I am expecting to see 2100 files");
+
+		for ($i = 1; $i <= 2100; $i++)
+		{
+			$key = sprintf('%stest_%04u.dat', static::PATH_PREFIX, $i);
+
+			static::assert(array_key_exists($key, $listing), sprintf('Results should list object %s', $key));
+		}
+
+		return true;
+	}
+
+	public static function testGetHundred(Connector $s3, array $options): bool
+	{
+		$listing = $s3->getBucket($options['bucket'], static::PATH_PREFIX, null, 100);
+
+		static::assert(is_array($listing), "The files listing must be an array");
+		static::assert(count($listing) === 100, "I am expecting to see 100 files");
+
+		for ($i = 1; $i <= 100; $i++)
+		{
+			$key = sprintf('%stest_%04u.dat', static::PATH_PREFIX, $i);
+
+			static::assert(array_key_exists($key, $listing), sprintf('Results should list object %s', $key));
+		}
+
+		return true;
+	}
+
+	public static function testGetElevenHundred(Connector $s3, array $options): bool
+	{
+		$listing = $s3->getBucket($options['bucket'], static::PATH_PREFIX, null, 1100);
+
+		static::assert(is_array($listing), "The files listing must be an array");
+		static::assert(count($listing) === 1100, "I am expecting to see 1100 files");
+
+		for ($i = 1; $i <= 1100; $i++)
+		{
+			$key = sprintf('%stest_%04u.dat', static::PATH_PREFIX, $i);
+
+			static::assert(array_key_exists($key, $listing), sprintf('Results should list object %s', $key));
+		}
+
+		return true;
+	}
+
+	public static function testGetLastHundred(Connector $s3, array $options): bool
+	{
+		$listing = $s3->getBucket($options['bucket'], static::PATH_PREFIX . 'test_20', null);
+
+		static::assert(is_array($listing), "The files listing must be an array");
+		static::assert(count($listing) === 100, "I am expecting to see 100 files");
+
+		for ($i = 2000; $i <= 2099; $i++)
+		{
+			$key = sprintf('%stest_%04u.dat', static::PATH_PREFIX, $i);
+
+			static::assert(array_key_exists($key, $listing), sprintf('Results should list object %s', $key));
+		}
+
+		return true;
+	}
+
+}

+ 103 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/Multipart.php

@@ -0,0 +1,103 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\MiniTest\Test;
+
+
+use Akeeba\S3\Connector;
+
+class Multipart extends BigFiles
+{
+	public static function setup(Connector $s3, array $options): void
+	{
+		static::$multipart = true;
+
+		parent::setup($s3, $options);
+	}
+
+	public static function upload5MBString(Connector $s3, array $options): bool
+	{
+		$result = parent::upload5MBString($s3, $options);
+
+		$expectedChunks = 1;
+		static::assert(static::$numberOfChunks === $expectedChunks, sprintf("Expected %s chunks, upload complete in %s chunks", $expectedChunks, static::$numberOfChunks));
+
+		return $result;
+	}
+
+	public static function upload6MBString(Connector $s3, array $options): bool
+	{
+		$result = parent::upload6MBString($s3, $options);
+
+		$expectedChunks = 2;
+		static::assert(static::$numberOfChunks === $expectedChunks, sprintf("Expected %s chunks, upload complete in %s chunks", $expectedChunks, static::$numberOfChunks));
+
+		return $result;
+	}
+
+	public static function upload10MBString(Connector $s3, array $options): bool
+	{
+		$result = parent::upload10MBString($s3, $options);
+
+		$expectedChunks = 2;
+		static::assert(static::$numberOfChunks === $expectedChunks, sprintf("Expected %s chunks, upload complete in %s chunks", $expectedChunks, static::$numberOfChunks));
+
+		return $result;
+	}
+
+	public static function upload11MBString(Connector $s3, array $options): bool
+	{
+		$result = parent::upload11MBString($s3, $options);
+
+		$expectedChunks = 3;
+		static::assert(static::$numberOfChunks === $expectedChunks, sprintf("Expected %s chunks, upload complete in %s chunks", $expectedChunks, static::$numberOfChunks));
+
+		return $result;
+	}
+
+	public static function upload5MBFile(Connector $s3, array $options): bool
+	{
+		$result = parent::upload5MBFile($s3, $options);
+
+		$expectedChunks = 1;
+		static::assert(static::$numberOfChunks === $expectedChunks, sprintf("Expected %s chunks, upload complete in %s chunks", $expectedChunks, static::$numberOfChunks));
+
+		return $result;
+	}
+
+	public static function upload6MBFile(Connector $s3, array $options): bool
+	{
+		$result = parent::upload6MBFile($s3, $options);
+
+		$expectedChunks = 2;
+		static::assert(static::$numberOfChunks === $expectedChunks, sprintf("Expected %s chunks, upload complete in %s chunks", $expectedChunks, static::$numberOfChunks));
+
+		return $result;
+	}
+
+	public static function upload10MBFile(Connector $s3, array $options): bool
+	{
+		$result = parent::upload10MBFile($s3, $options);
+
+		$expectedChunks = 2;
+		static::assert(static::$numberOfChunks === $expectedChunks, sprintf("Expected %s chunks, upload complete in %s chunks", $expectedChunks, static::$numberOfChunks));
+
+		return $result;
+	}
+
+	public static function upload11MBFile(Connector $s3, array $options): bool
+	{
+		$result = parent::upload11MBFile($s3, $options);
+
+		$expectedChunks = 3;
+		static::assert(static::$numberOfChunks === $expectedChunks, sprintf("Expected %s chunks, upload complete in %s chunks", $expectedChunks, static::$numberOfChunks));
+
+		return $result;
+	}
+}

+ 78 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/SignedURLs.php

@@ -0,0 +1,78 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\MiniTest\Test;
+
+
+use Akeeba\S3\Acl;
+use Akeeba\S3\Connector;
+use Akeeba\S3\Input;
+use RuntimeException;
+
+class SignedURLs extends AbstractTest
+{
+	public static function signedURLPublicObject(Connector $s3, array $options): bool
+	{
+		return static::signedURL($s3, $options, Acl::ACL_PUBLIC_READ);
+	}
+
+	public static function signedURLPublicObjectSpaces(Connector $s3, array $options): bool
+	{
+		return static::signedURL($s3, array_merge($options, [
+			'spaces' => true
+		]), Acl::ACL_PUBLIC_READ);
+	}
+
+	public static function signedURLPrivateObject(Connector $s3, array $options): bool
+	{
+		return static::signedURL($s3, $options, Acl::ACL_PRIVATE);
+	}
+
+	public static function signedURLPrivateObjectSpaces(Connector $s3, array $options): bool
+	{
+		return static::signedURL($s3, array_merge($options, [
+			'spaces' => true
+		]), Acl::ACL_PRIVATE);
+	}
+
+	private static function signedURL(Connector $s3, array $options, string $aclPrivilege): bool
+	{
+		$spaces   = isset($options['spaces']) && boolval($options['spaces']);
+		$tempData = static::getRandomData(AbstractTest::TEN_KB);
+		$input    = Input::createFromData($tempData);
+		$prefix   = $spaces ? 'test file' : 'test';
+		$uri      = $prefix . '.' . hash('md5', microtime(false)) . '.dat';
+
+		$s3->putObject($input, $options['bucket'], $uri, $aclPrivilege);
+
+		$downloadURL    = $s3->getAuthenticatedURL($options['bucket'], $uri, null, $options['ssl']);
+
+		echo "\n\tDownload URL: $downloadURL\n";
+
+		$downloadedData = @file_get_contents($downloadURL);
+
+		try
+		{
+			$s3->deleteObject($options['bucket'], $uri);
+		}
+		catch (\Exception $e)
+		{
+			// Ignore deletion errors
+		}
+
+		if ($downloadedData === false)
+		{
+			throw new RuntimeException("Failed to download from signed URL ‘{$downloadURL}′");
+		}
+
+		static::assert(static::areStringsEqual($tempData, $downloadedData), "Wrong data received from signed URL ‘{$downloadURL}′");
+
+		return true;
+	}
+}

+ 43 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/SingleSmallFile.php

@@ -0,0 +1,43 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\MiniTest\Test;
+
+use Akeeba\S3\Connector;
+use Akeeba\S3\Input;
+
+/**
+ * Upload, download and delete small files (under 1MB) using a string source
+ *
+ * @package Akeeba\MiniTest\Test
+ */
+class SingleSmallFile extends AbstractTest
+{
+	public static function upload(Connector $s3, array $options): bool
+	{
+		$uri        = 'test.txt';
+		$sourceData = <<< TEXT
+This is a small text file.
+TEXT;
+
+
+		// Upload the data. Throws exception if it fails.
+		$bucket = $options['bucket'];
+		$input  = Input::createFromData($sourceData);
+
+		$s3->putObject($input, $bucket, $uri);
+
+		$downloadedData = $s3->getObject($bucket, $uri);
+		$result         = static::areStringsEqual($sourceData, $downloadedData);
+
+		$s3->deleteObject($bucket, $uri);
+
+		return $result ?? true;
+	}
+}

+ 109 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/SmallFiles.php

@@ -0,0 +1,109 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\MiniTest\Test;
+
+
+use Akeeba\S3\Connector;
+use Akeeba\S3\Input;
+
+/**
+ * Upload, download and delete small files (under 1MB) using a file source
+ *
+ * @package Akeeba\MiniTest\Test
+ */
+class SmallFiles extends AbstractTest
+{
+	/**
+	 * Should I download the file after uploading it to test for contents consistency?
+	 *
+	 * @var bool
+	 */
+	protected static $downloadAfter = true;
+
+	/**
+	 * Should I delete the uploaded file after the test case is done?
+	 *
+	 * @var bool
+	 */
+	protected static $deleteRemote = true;
+
+	public static function upload10KbRoot(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, AbstractTest::TEN_KB, 'root_10kb.dat');
+	}
+
+	public static function upload10KbRootGreek(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, AbstractTest::TEN_KB, 'δοκιμή_10kb.dat');
+	}
+
+	public static function upload10KbFolderGreek(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, AbstractTest::TEN_KB, 'ο_φάκελός_μου/δοκιμή_10kb.dat');
+	}
+
+	public static function upload600KbRoot(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, AbstractTest::SIX_HUNDRED_KB, 'root_600kb.dat');
+	}
+
+	public static function upload10KbFolder(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, AbstractTest::TEN_KB, 'my_folder/10kb.dat');
+	}
+
+	public static function upload600KbFolder(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, AbstractTest::SIX_HUNDRED_KB, 'my_folder/600kb.dat');
+	}
+
+	protected static function upload(Connector $s3, array $options, int $size, string $uri): bool
+	{
+		// Randomize the name. Required for archive buckets where you cannot overwrite data.
+		$dotPos = strrpos($uri, '.');
+		$uri    = substr($uri, 0, $dotPos) . '.' . hash('md5', microtime(false)) . substr($uri, $dotPos);
+
+		// Create a file with random data
+		$sourceFile = static::createFile($size);
+
+		// Upload the file. Throws exception if it fails.
+		$bucket = $options['bucket'];
+		$input  = Input::createFromFile($sourceFile);
+
+		$s3->putObject($input, $bucket, $uri);
+
+		// Tentatively accept that this method succeeded.
+		$result = true;
+
+		// Should I download the file and compare its contents?
+		if (static::$downloadAfter)
+		{
+			// Donwload the data. Throws exception if it fails.
+			$downloadedFile = tempnam(static::getTempFolder(), 'as3');
+			$s3->getObject($bucket, $uri, $downloadedFile);
+
+			// Compare the file contents.
+			$result = static::areFilesEqual($sourceFile, $downloadedFile);
+		}
+
+		// Remove the local files
+		@unlink($sourceFile);
+		@unlink($downloadedFile);
+
+		// Should I delete the remotely stored file?
+		if (static::$deleteRemote)
+		{
+			// Delete the remote file. Throws exception if it fails.
+			$s3->deleteObject($bucket, $uri);
+		}
+
+		return $result;
+	}
+}

+ 28 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/SmallFilesNoDelete.php

@@ -0,0 +1,28 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\MiniTest\Test;
+
+
+use Akeeba\S3\Connector;
+
+/**
+ * Upload and download small files (under 1MB) using a file source
+ *
+ * @package Akeeba\MiniTest\Test
+ */
+class SmallFilesNoDelete extends SmallFiles
+{
+	public static function setup(Connector $s3, array $options): void
+	{
+		static::$deleteRemote = false;
+
+		parent::setup($s3, $options);
+	}
+}

+ 31 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/SmallFilesOnlyUpload.php

@@ -0,0 +1,31 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\MiniTest\Test;
+
+
+use Akeeba\S3\Connector;
+
+/**
+ * Upload small files (under 1MB) using a file source
+ *
+ * @package Akeeba\MiniTest\Test
+ */
+class SmallFilesOnlyUpload extends SmallFiles
+{
+	public static function setup(Connector $s3, array $options): void
+	{
+		static::$deleteRemote  = false;
+		static::$downloadAfter = false;
+
+		parent::setup($s3, $options);
+	}
+
+
+}

+ 57 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/SmallInlineFiles.php

@@ -0,0 +1,57 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\MiniTest\Test;
+
+use Akeeba\S3\Connector;
+use Akeeba\S3\Input;
+
+/**
+ * Upload, download and delete small files (under 1MB) using a string source
+ *
+ * @package Akeeba\MiniTest\Test
+ */
+class SmallInlineFiles extends SmallFiles
+{
+	protected static function upload(Connector $s3, array $options, int $size, string $uri): bool
+	{
+		// Randomize the name. Required for archive buckets where you cannot overwrite data.
+		$dotPos = strrpos($uri, '.');
+		$uri    = substr($uri, 0, $dotPos) . '.' . hash('md5', microtime(false)) . substr($uri, $dotPos);
+
+		// Create some random data to upload
+		$sourceData = static::getRandomData($size);
+
+		// Upload the data. Throws exception if it fails.
+		$bucket = $options['bucket'];
+		$input  = Input::createFromData($sourceData);
+
+		$s3->putObject($input, $bucket, $uri);
+
+		// Tentatively accept that this method succeeded.
+		$result = true;
+
+		// Should I download the file and compare its contents with my random data?
+		if (static::$downloadAfter)
+		{
+			$downloadedData = $s3->getObject($bucket, $uri);
+
+			$result = static::areStringsEqual($sourceData, $downloadedData);
+		}
+
+		// Should I delete the remotely stored file?
+		if (static::$deleteRemote)
+		{
+			// Delete the remote file. Throws exception if it fails.
+			$s3->deleteObject($bucket, $uri);
+		}
+
+		return $result;
+	}
+}

+ 28 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/SmallInlineFilesNoDelete.php

@@ -0,0 +1,28 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\MiniTest\Test;
+
+
+use Akeeba\S3\Connector;
+
+/**
+ * Upload and download small files (under 1MB) using a string source
+ *
+ * @package Akeeba\MiniTest\Test
+ */
+class SmallInlineFilesNoDelete extends SmallInlineFiles
+{
+	public static function setup(Connector $s3, array $options): void
+	{
+		static:: $deleteRemote = false;
+
+		parent::setup($s3, $options);
+	}
+}

+ 30 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/SmallInlineFilesOnlyUpload.php

@@ -0,0 +1,30 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\MiniTest\Test;
+
+
+use Akeeba\S3\Connector;
+
+/**
+ * Upload small files (under 1MB) using a string source
+ *
+ * @package Akeeba\MiniTest\Test
+ */
+class SmallInlineFilesOnlyUpload extends SmallInlineFiles
+{
+	public static function setup(Connector $s3, array $options): void
+	{
+		static::$deleteRemote  = false;
+		static::$downloadAfter = false;
+
+		parent::setup($s3, $options);
+	}
+
+}

+ 131 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/SmallInlineXMLFiles.php

@@ -0,0 +1,131 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\MiniTest\Test;
+
+
+use Akeeba\S3\Connector;
+use Akeeba\S3\Input;
+
+/**
+ * Upload, download and delete small XML files (under 1MB) using a string source
+ *
+ * @package Akeeba\MiniTest\Test
+ */
+class SmallInlineXMLFiles extends SmallFiles
+{
+	public static function upload10KbRoot(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, AbstractTest::TEN_KB, 'root_10kb.xml');
+	}
+
+	public static function upload10KbRootGreek(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, AbstractTest::TEN_KB, 'δοκιμή_10kb.xml');
+	}
+
+	public static function upload10KbFolderGreek(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, AbstractTest::TEN_KB, 'ο_φάκελός_μου/δοκιμή_10kb.xml');
+	}
+
+	public static function upload600KbRoot(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, AbstractTest::SIX_HUNDRED_KB, 'root_600kb.xml');
+	}
+
+	public static function upload10KbFolder(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, AbstractTest::TEN_KB, 'my_folder/10kb.xml');
+	}
+
+	public static function upload600KbFolder(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, AbstractTest::SIX_HUNDRED_KB, 'my_folder/600kb.xml');
+	}
+
+	protected static function upload(Connector $s3, array $options, int $size, string $uri): bool
+	{
+		// Randomize the name. Required for archive buckets where you cannot overwrite data.
+		$dotPos = strrpos($uri, '.');
+		$uri    = substr($uri, 0, $dotPos) . '.' . hash('md5', microtime(false)) . substr($uri, $dotPos);
+
+		// Create some random data to upload
+		$sourceData = static::createXMLFile($size);
+
+		// Upload the data. Throws exception if it fails.
+		$bucket = $options['bucket'];
+		$input  = Input::createFromData($sourceData);
+
+		$s3->putObject($input, $bucket, $uri);
+
+		// Tentatively accept that this method succeeded.
+		$result = true;
+
+		// Should I download the file and compare its contents with my random data?
+		if (static::$downloadAfter)
+		{
+			$downloadedData = $s3->getObject($bucket, $uri);
+
+			$result = static::areStringsEqual($sourceData, $downloadedData);
+		}
+
+		// Should I delete the remotely stored file?
+		if (static::$deleteRemote)
+		{
+			// Delete the remote file. Throws exception if it fails.
+			$s3->deleteObject($bucket, $uri);
+		}
+
+		return $result;
+	}
+
+	private static function createXMLFile(int $size): string
+	{
+		$out = <<< XML
+<?xml version="1.0" encoding="utf-8" ?>
+<root>
+XML;
+
+		$chunks = floor(($size - 55) / 1024);
+
+		for ($i = 1; $i <= $chunks; $i++)
+		{
+			$randomBlock = static::genRandomData(1024 - 63);
+			$out .= <<< XML
+		<element>
+			<id>$i</id>
+			<data><![CDATA[$randomBlock]]></data>
+		</element>
+XML;
+
+		}
+
+
+		$out .= <<< XML
+</root>
+XML;
+
+		return $out;
+	}
+
+	private static function genRandomData(int $length): string
+	{
+		$chars     = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890';
+		$maxLength = strlen($chars) - 1;
+		$salt      = '';
+
+		for ($i = 0; $i < $length; $i++)
+		{
+			$salt .= substr($chars, random_int(0, $maxLength), 1);
+		}
+
+		return $salt;
+	}
+}

+ 74 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/Test/StorageClasses.php

@@ -0,0 +1,74 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\MiniTest\Test;
+
+
+use Akeeba\S3\Acl;
+use Akeeba\S3\Connector;
+use Akeeba\S3\Input;
+use Akeeba\S3\StorageClass;
+
+class StorageClasses extends AbstractTest
+{
+	protected static $downloadAfter = true;
+
+	protected static $deleteRemote = true;
+
+	public static function uploadRRS(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, static::TEN_KB, 'rrs_test_10kb.dat', StorageClass::REDUCED_REDUNDANCY);
+	}
+
+	public static function uploadIntelligentTiering(Connector $s3, array $options): bool
+	{
+		return static::upload($s3, $options, static::TEN_KB, 'rrs_test_10kb.dat', StorageClass::INTELLIGENT_TIERING);
+	}
+
+	protected static function upload(Connector $s3, array $options, int $size, string $uri, string $storageClass = null)
+	{
+		// Randomize the name. Required for archive buckets where you cannot overwrite data.
+		$dotPos = strrpos($uri, '.');
+		$uri    = substr($uri, 0, $dotPos) . '.' . hash('md5', microtime(false)) . substr($uri, $dotPos);
+
+		// Create some random data to upload
+		$sourceData = static::getRandomData($size);
+
+		// Upload the data. Throws exception if it fails.
+		$bucket = $options['bucket'];
+		$input  = Input::createFromData($sourceData);
+
+		// Get the headers
+		$headers = [];
+		StorageClass::setStorageClass($headers, $storageClass);
+
+		$s3->putObject($input, $bucket, $uri, Acl::ACL_PRIVATE, $headers);
+
+		// Tentatively accept that this method succeeded.
+		$result = true;
+
+		// Should I download the file and compare its contents with my random data?
+		if (static::$downloadAfter)
+		{
+			$downloadedData = $s3->getObject($bucket, $uri);
+
+			$result = static::areStringsEqual($sourceData, $downloadedData);
+		}
+
+		// Should I delete the remotely stored file?
+		if (static::$deleteRemote)
+		{
+			// Delete the remote file. Throws exception if it fails.
+			$s3->deleteObject($bucket, $uri);
+		}
+
+		return $result;
+	}
+
+}

+ 303 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/config.dist.php

@@ -0,0 +1,303 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+// Create the 2100 test files in the bucket?
+define('CREATE_2100_FILES', true);
+
+/**
+ * Configure the connection options for S3 and S3-compatible services here. Use them in the $testConfigurations array.
+ */
+$serviceConfigurations = [
+	's3-v4'         => [
+		'access'      => 'AK0123456789BCDEFGHI',
+		'secret'      => 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
+		'region'      => 'eu-west-1',
+		'bucket'      => 'mybucket',
+		'signature'   => 'v4',
+		'dualstack'   => false,
+		'path_access' => false,
+		'ssl'         => false,
+		'endpoint'    => null,
+	],
+	's3-v2'         => [
+		'access'      => 'AK0123456789BCDEFGHI',
+		'secret'      => 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX',
+		'region'      => 'eu-west-1',
+		'bucket'      => 'mybucket',
+		'signature'   => 'v2',
+		'dualstack'   => false,
+		'path_access' => false,
+		'ssl'         => false,
+		'endpoint'    => null,
+	],
+	'localstack-v4' => [
+		'access'      => 'KYLOREN',
+		'secret'      => 'BenSolo',
+		'region'      => 'us-east-1',
+		'bucket'      => 'test',
+		'signature'   => 'v4',
+		'dualstack'   => false,
+		'path_access' => true,
+		'ssl'         => false,
+		'endpoint'    => 'localhost.localstack.cloud:4566',
+	],
+	'localstack-v2' => [
+		'access'      => 'KYLOREN',
+		'secret'      => 'BenSolo',
+		'region'      => 'us-east-1',
+		'bucket'      => 'test',
+		'signature'   => 'v2',
+		'dualstack'   => false,
+		'path_access' => true,
+		'ssl'         => false,
+		'endpoint'    => 'localhost.localstack.cloud:4566',
+	],
+];
+
+/**
+ * Test EVERYTHING.
+ */
+$allTheTests = [
+	'BucketsList',
+	'BucketLocation',
+	'HeadObject',
+	'ListFiles',
+	'SmallFiles',
+	'SmallInlineFiles',
+	'SmallFilesNoDelete',
+	'SmallFilesOnlyUpload',
+	'SmallInlineFilesNoDelete',
+	'SmallInlineFilesOnlyUpload',
+	'SmallInlineXMLFiles',
+	'BigFiles',
+	'Multipart',
+	'StorageClasses',
+	'SignedURLs',
+];
+
+if (CREATE_2100_FILES)
+{
+	$allTheTests[] = 'ListThousandsOfFiles';
+}
+
+/**
+ * Tests for standard key pairs allowing us to read, write and delete
+ *
+ * This is the main test suite
+ */
+$standardTests = [
+	'BucketsList',
+	'BucketLocation',
+	'HeadObject',
+	'ListFiles',
+	'SmallFiles',
+	'SmallInlineFiles',
+	'SmallInlineXMLFiles',
+	'BigFiles',
+	'Multipart',
+	'StorageClasses',
+	'SignedURLs',
+];
+
+/**
+ * Tests for key pairs or buckets which do NOT allow us to delete, but DO allow us to write and read data
+ *
+ * Example: archival buckets
+ */
+$noDeleteTests = [
+	'SmallFilesNoDelete',
+	'SmallInlineFilesNoDelete',
+];
+
+/**
+ * Tests for key pairs which do NOT allow us to read, but DO allow us to write/delete
+ *
+ * Example: write-only key pairs per my documentation information from 2011 :)
+ */
+$writeOnlyTests = [
+	'SmallFilesOnlyUpload',
+	'SmallInlineFilesOnlyUpload',
+];
+
+/**
+ * These are the individual test configurations.
+ *
+ * Each configuration consists of two keys:
+ *
+ * * **configuration**  Overrides to the default configuration.
+ * * **tests**          The names of the test classes to execute. Use the format ['classname', 'method'] to execute
+ *                      specific test methods only.
+ */
+$testConfigurations = [
+// Format of each
+//	'Description of this configuration' => array(
+//		'configuration' => array(
+//			// You can skip one or more keys. The defaults will be used.
+//			'access' => 'a different access key',
+//			'secret' => 'a different secret key',
+//			'region' => 'eu-west-1',
+//			'bucket' => 'different_example',
+//			'signature' => 'v2',
+//			'dualstack' => true,
+//			'path_access' => true,
+//			'ssl' => true,
+//          // Only if you want to use a custom, non-Amazon endpoint
+//          'endpoint' => null,
+//		),
+//		'tests' => array(
+//          // Use a start to run all tests
+//			'*',
+//          // Alternatively you can define single test classes:
+//			'SmallFiles',
+//          // ..or specific tests:
+//			array('SmallFiles', 'upload10KbRoot'),
+//		)
+//	),
+	/**/
+
+	/**
+	 * These are the standard tests we run for each region and key pair.
+	 *
+	 * For all available regions please consult https://docs.aws.amazon.com/general/latest/gr/s3.html
+	 *
+	 * It is recommended to run against the following regions:
+	 * - eu-east-1     The original Amazon S3 region, it often has special meaning in APIs.
+	 * - eu-west-1     Ireland. The original EU region for S3, as a test for the non-default region.
+	 * - eu-central-1  Frankfurt. This region –like all newer regions– only allows v4 signatures!
+	 * - cn-north-1    Beijing, China. Requires running it from inside China.
+	 * - NON-AMAZON    A custom endpoint for a third party, S3-compatible API. Ideally one for v2 and one for v4.
+	 *
+	 * Further to that test the following:
+	 * - Write-only, bucket-restricted keys
+	 * - Read/write, bucket-restricted keys
+	 * - Buckets with dots
+	 * - Buckets with uppercase letters
+	 * - Buckets with international letters
+	 * - Access from within EC2
+	 */
+
+	// Amazon S3, v2 signatures
+	'S3, v2, subdomain, single stack'  => [
+		'configuration' => array_merge(
+			$serviceConfigurations['s3-v2'],
+			[
+				'dualstack'   => false,
+				'path_access' => false,
+			]
+		),
+		'tests'         => $standardTests,
+		'skip'          => false,
+	],
+	'S3, v2, subdomain, dual stack'    => [
+		'configuration' => array_merge(
+			$serviceConfigurations['s3-v2'],
+			[
+				'dualstack'   => true,
+				'path_access' => false,
+			]
+		),
+		'tests'         => $standardTests,
+		'skip'          => false,
+	],
+	'S3, v2, path, single stack'  => [
+		'configuration' => array_merge(
+			$serviceConfigurations['s3-v2'],
+			[
+				'dualstack'   => false,
+				'path_access' => true,
+			]
+		),
+		'tests'         => $standardTests,
+		'skip'          => false,
+	],
+	'S3, v2, path, dual stack'    => [
+		'configuration' => array_merge(
+			$serviceConfigurations['s3-v2'],
+			[
+				'dualstack'   => true,
+				'path_access' => true,
+			]
+		),
+		'tests'         => $standardTests,
+		'skip'          => false,
+	],
+
+	// Amazon S3, v4 signatures
+	'S3, v4, subdomain, single stack'  => [
+		'configuration' => array_merge(
+			$serviceConfigurations['s3-v4'],
+			[
+				'dualstack'   => false,
+				'path_access' => false,
+			]
+		),
+		'tests'         => $standardTests,
+		'skip'          => false,
+	],
+	'S3, v4, subdomain, dual stack'    => [
+		'configuration' => array_merge(
+			$serviceConfigurations['s3-v4'],
+			[
+				'dualstack'   => true,
+				'path_access' => false,
+			]
+		),
+		'tests'         => $standardTests,
+		'skip'          => false,
+	],
+	'S3, v4, path, single stack'  => [
+		'configuration' => array_merge(
+			$serviceConfigurations['s3-v4'],
+			[
+				'dualstack'   => false,
+				'path_access' => true,
+			]
+		),
+		'tests'         => $standardTests,
+		'skip'          => false,
+	],
+	'S3, v4, path, dual stack'    => [
+		'configuration' => array_merge(
+			$serviceConfigurations['s3-v4'],
+			[
+				'dualstack'   => true,
+				'path_access' => true,
+			]
+		),
+		'tests'         => $standardTests,
+		'skip'          => false,
+	],
+
+	// LocalStack
+	'LocalStack, V2 (always path access, single stack)' => [
+		'configuration' => $serviceConfigurations['localstack-v2'],
+		'tests'         => $allTheTests,
+		'skip'          => false,
+	],
+
+	'LocalStack, V4 (always path access, single stack)' => [
+		'configuration' => $serviceConfigurations['localstack-v4'],
+		'tests'         => $allTheTests,
+		'skip'          => false,
+	],
+
+	/**
+	 * In the real config file we also have tests running on:
+	 *
+	 * - Wasabi, v2, path-style access.
+	 * - Wasabi, v4, path-style access.
+	 * - Synology C2, subdomain access.
+	 *
+	 * See [[NOTES.md]] for more information. If you have another environment you think we should test with please
+	 * update NOTES.md and make a pull request, along with the reasoning behind it.
+	 *
+	 * There are known failures for some cases, notably LocalStack v4 (something is amiss?) and C2 (necessary flag is
+	 * not yet supported by the minitest framework).
+	 */
+];

+ 456 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/minitest/minitest.php

@@ -0,0 +1,456 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+use Akeeba\S3\Configuration;
+use Akeeba\S3\Connector;
+
+/**
+ * The Miniature Test Framework For The Akeeba S3 Library
+ *
+ * This is a self-contained test-suite runner. Running minitest.php will execute all the tests against all
+ * configurations set up in the config.php file. When it's decked out with all real world examples this can take many
+ * hours to complete (it's not as "mini" as its name would like you to believe).
+ *
+ * Please read NOTES.md before proceeding and do keep in mind that some tests may fail for reasons outside the control
+ * of the library such as network conditions, whether PHP has a configured Certification Authority cache etc.
+ *
+ * As to why we didn't use Unit Tests: Elementary, dear Watson. Unit Tests are great when testing your code against a
+ * specification. In this case, the specification would be the Amazon S3 API documentation. Sounds great in theory, but
+ * not even Amazon itself works according to its own documentation, let alone the third party "S3-compatible" services
+ * which each one implements its own interpretation of that documentation. Therefore, slow-as-heck integration testing
+ * is the only way to do any kind of meaningful testing.
+ */
+
+// Necessary for including the library
+define('AKEEBAENGINE', 1);
+
+if (!file_exists(__DIR__ . '/../vendor/autoload.php'))
+{
+	die ('Please run composer install before running the mini test suite.');
+}
+
+// Use Composer's autoloader to load the library
+/** @var \Composer\Autoload\ClassLoader $autoloader */
+$autoloader = require_once(__DIR__ . '/../vendor/autoload.php');
+
+// Add the minitest PSR-4 path map to Composer's autoloader
+$autoloader->addPsr4('Akeeba\\MiniTest\\', __DIR__);
+
+function getAllTestClasses(): array
+{
+	static $testClasses = [];
+
+	if (!empty($testClasses))
+	{
+		return $testClasses;
+	}
+
+	$folder = __DIR__ . '/Test';
+	$di     = new DirectoryIterator($folder);
+
+	foreach ($di as $entry)
+	{
+		if ($entry->isDot() || !$entry->isFile())
+		{
+			continue;
+		}
+
+		$baseName  = $entry->getBasename('.php');
+		$className = '\\Akeeba\\MiniTest\\Test\\' . $baseName;
+
+		if (!class_exists($className))
+		{
+			continue;
+		}
+
+		$reflectedClass = new ReflectionClass($className);
+
+		if ($reflectedClass->isAbstract())
+		{
+			continue;
+		}
+
+		$testClasses[] = $className;
+	}
+
+	return $testClasses;
+}
+
+function getTestMethods(string $className): array
+{
+	static $classMethodMap = [];
+
+	if (isset($classMethodMap[$className]))
+	{
+		return $classMethodMap[$className];
+	}
+
+	$classMethodMap[$className] = [];
+
+	if (!class_exists($className))
+	{
+		return $classMethodMap[$className];
+	}
+
+	$reflectedClass = new ReflectionClass($className);
+	$methods        = $reflectedClass->getMethods(ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_STATIC);
+
+	$classMethodMap[$className] = array_map(
+		function (ReflectionMethod $refMethod) {
+			if ($refMethod->isPrivate() || $refMethod->isProtected() || $refMethod->isAbstract())
+			{
+				return null;
+			}
+
+			if (!$refMethod->isStatic())
+			{
+				return null;
+			}
+
+			return $refMethod->getName();
+		}, $methods
+	);
+
+	$classMethodMap[$className] = array_filter(
+		$classMethodMap[$className], function ($method) {
+		if (is_null($method))
+		{
+			return false;
+		}
+
+		if (in_array($method, ['setup', 'teardown']))
+		{
+			return false;
+		}
+
+		return true;
+	}
+	);
+
+	return $classMethodMap[$className];
+}
+
+function simplifyClassName(?string $className): string
+{
+	if (empty($className))
+	{
+		return '';
+	}
+
+	$namespace = __NAMESPACE__ . '\\Test\\';
+
+	if (strpos($className, $namespace) === 0)
+	{
+		return substr($className, strlen($namespace));
+	}
+
+	return $className;
+}
+
+if (!file_exists(__DIR__ . '/config.php'))
+{
+	die ('Please rename config.dist.php to config.php and customise it before running the mini test suite.');
+}
+
+require __DIR__ . '/config.php';
+
+global $testConfigurations;
+
+$total      = 0;
+$broken     = 0;
+$failed     = 0;
+$successful = 0;
+
+foreach ($testConfigurations as $description => $setup)
+{
+	echo "▶ " . $description . PHP_EOL;
+	echo str_repeat('〰', 80) . PHP_EOL . PHP_EOL;
+
+	if ($setup['skip'] ?? false)
+	{
+		echo "\t🤡 Skipping\n\n";
+
+		continue;
+	}
+
+	// Extract the configuration options
+	if (!isset($setup['configuration']))
+	{
+		$setup['configuration'] = [];
+	}
+
+	$configOptions = array_merge(
+		[
+			'access'                    => defined('DEFAULT_ACCESS_KEY') ? DEFAULT_ACCESS_KEY : null,
+			'secret'                    => defined('DEFAULT_SECRET_KEY') ? DEFAULT_SECRET_KEY : null,
+			'region'                    => defined('DEFAULT_REGION') ? DEFAULT_REGION : null,
+			'bucket'                    => defined('DEFAULT_BUCKET') ? DEFAULT_BUCKET : null,
+			'signature'                 => defined('DEFAULT_SIGNATURE') ? DEFAULT_SIGNATURE : null,
+			'dualstack'                 => defined('DEFAULT_DUALSTACK') ? DEFAULT_DUALSTACK : null,
+			'path_access'               => defined('DEFAULT_PATH_ACCESS') ? DEFAULT_PATH_ACCESS : null,
+			'ssl'                       => defined('DEFAULT_SSL') ? DEFAULT_SSL : null,
+			'endpoint'                  => defined('DEFAULT_ENDPOINT') ? constant('DEFAULT_ENDPOINT') : null,
+			'alternateDateHeaderFormat' => defined('DEFAULT_ALTERNATE_DATE_FORMAT') ? constant(
+				'DEFAULT_ALTERNATE_DATE_FORMAT'
+			) : false,
+			'useHTTPDateHeader'         => defined('DEFAULT_USE_HTTP_HEADER') ? constant('DEFAULT_USE_HTTP_HEADER')
+				: false,
+		], $setup['configuration']
+	);
+
+	// Extract the test classes/methods to run
+	if (!isset($setup['tests']))
+	{
+		$setup['tests'] = getAllTestClasses();
+	}
+
+	$tests = $setup['tests'];
+
+	if (!is_array($tests) || (is_array($tests) && in_array('*', $tests)))
+	{
+		$tests = getAllTestClasses();
+	}
+
+	// Create the S3 configuration object
+	$s3Configuration = new Configuration(
+		$configOptions['access'], $configOptions['secret'], $configOptions['signature'], $configOptions['region']
+	);
+	$s3Configuration->setRegion($configOptions['region']);
+	$s3Configuration->setSignatureMethod($configOptions['signature']);
+
+	if (!is_null($configOptions['endpoint']))
+	{
+		$s3Configuration->setEndpoint($configOptions['endpoint']);
+		// We need to redo this because setting the endpoint may reset these options
+		$s3Configuration->setRegion($configOptions['region']);
+		$s3Configuration->setSignatureMethod($configOptions['signature']);
+	}
+
+	$s3Configuration->setUseDualstackUrl($configOptions['dualstack']);
+	$s3Configuration->setUseLegacyPathStyle($configOptions['path_access']);
+	$s3Configuration->setSSL($configOptions['ssl']);
+
+	// Feature flags
+	if (isset($configOptions['alternateDateHeaderFormat']))
+	{
+		$s3Configuration->setAlternateDateHeaderFormat((bool) $configOptions['alternateDateHeaderFormat']);
+	}
+
+	if (isset($configOptions['useHTTPDateHeader']))
+	{
+		$s3Configuration->setUseHTTPDateHeader((bool) $configOptions['useHTTPDateHeader']);
+	}
+
+	if (isset($configOptions['preSignedBucketInURL']))
+	{
+		$s3Configuration->setPreSignedBucketInURL((bool) $configOptions['preSignedBucketInURL']);
+	}
+
+	// Create the connector object
+	$s3Connector = new Connector($s3Configuration);
+
+	// Run the tests
+	foreach ($tests as $testInfo)
+	{
+		if (!is_array($testInfo))
+		{
+			$className = $testInfo;
+
+			if (!class_exists($className))
+			{
+				$className = '\\Akeeba\\MiniTest\\Test\\' . $className;
+			}
+
+			if (!class_exists($className))
+			{
+				$total++;
+				$broken++;
+				echo "  ⁉️ Test class {$className} not found." . PHP_EOL;
+
+				continue;
+			}
+
+			$testInfo = array_map(
+				function ($method) use ($className) {
+					return [$className, $method];
+				}, getTestMethods($className)
+			);
+		}
+		else
+		{
+			[$className, $method] = $testInfo;
+
+			if (!class_exists($className))
+			{
+				$className = '\\Akeeba\\MiniTest\\Test\\' . $className;
+			}
+
+			if (!class_exists($className))
+			{
+				$total++;
+				$broken++;
+				echo "  ⁉️ Test class {$className} not found." . PHP_EOL;
+
+				continue;
+			}
+
+			$testInfo = [
+				[$className, $method],
+			];
+		}
+
+		$firstOne            = false;
+		$className           = null;
+		$callableSetup       = null;
+		$callableTeardown    = null;
+		$simplifiedClassname = simplifyClassName($className);
+
+		if (!empty($testInfo))
+		{
+			$firstOne = array_shift($testInfo);
+			array_unshift($testInfo, $firstOne);
+		}
+
+		if ($firstOne)
+		{
+			[$className,] = $firstOne;
+
+			if ($className)
+			{
+				$callableSetup    = [$className, 'setup'];
+				$callableTeardown = [$className, 'teardown'];
+			}
+		}
+
+		if (is_callable($callableSetup))
+		{
+			[$classNameSetup, $method] = $callableSetup;
+			$simplifiedClassname = simplifyClassName($classNameSetup);
+			echo "  ⏱ Preparing {$simplifiedClassname}:{$method}…";
+			call_user_func($callableSetup, $s3Connector, $configOptions);
+			echo "\r     Prepared {$simplifiedClassname}   " . PHP_EOL;
+		}
+
+		foreach ($testInfo as $callable)
+		{
+			$total++;
+			[$className, $method] = $callable;
+
+			if (!class_exists($className))
+			{
+				$broken++;
+				echo "  ⁉️ Test class {$className} not found." . PHP_EOL;
+
+				continue;
+			}
+
+			if (!method_exists($className, $method))
+			{
+				$broken++;
+				echo "  ⁉️ Method {$method} not found in test class {$className}." . PHP_EOL;
+
+				continue;
+			}
+
+			echo "  ⏱ {$simplifiedClassname}:{$method}…";
+			$errorException = null;
+
+			try
+			{
+				$result = call_user_func([$className, $method], $s3Connector, $configOptions);
+			}
+			catch (Exception $e)
+			{
+				$result         = false;
+				$errorException = $e;
+			}
+
+			echo "\r  " . ($result ? '✔' : '🚨') . " {$simplifiedClassname}:{$method}  " . PHP_EOL;
+
+			if ($result)
+			{
+				$successful++;
+				continue;
+			}
+
+			$failed++;
+
+			if (is_null($errorException))
+			{
+				echo "    Returned false" . PHP_EOL;
+
+				continue;
+			}
+
+			echo "    {$errorException->getCode()} – {$errorException->getMessage()}" . PHP_EOL;
+			echo "    {$errorException->getFile()}({$errorException->getLine()})" . PHP_EOL . PHP_EOL;
+
+			$errorLines = explode("\n", $errorException->getTraceAsString());
+
+			foreach ($errorLines as $line)
+			{
+				echo "    $line" . PHP_EOL;
+			}
+		}
+
+		if (is_callable($callableTeardown))
+		{
+			[$className, $method] = $callableSetup;
+			echo "  ⏱ Tearing down {$className}:{$method}…";
+			call_user_func($callableTeardown, $s3Connector, $configOptions);
+			echo "\r     Tore down {$className}   " . PHP_EOL;
+		}
+	}
+
+	echo PHP_EOL;
+}
+
+echo PHP_EOL;
+echo str_repeat('⎺', 80) . PHP_EOL;
+echo PHP_EOL;
+
+echo "Summary:" . PHP_EOL;
+if ($broken)
+{
+	echo "  Broken     : $broken" . PHP_EOL;
+}
+if ($failed)
+{
+	echo "  Failed     : $failed" . PHP_EOL;
+}
+if ($successful)
+{
+	echo "  Successful : $successful" . PHP_EOL;
+}
+echo "  Total      : $total" . PHP_EOL . PHP_EOL;
+
+echo "Conclusion: " . PHP_EOL . "  ";
+
+if ($failed > 0)
+{
+	echo "❌ FAILED 😭😭😭" . PHP_EOL;
+
+	exit(1);
+}
+
+if ($successful === 0)
+{
+	echo "🔥 No tests executed! 🤪" . PHP_EOL;
+
+	exit (3);
+}
+
+if ($broken > 0)
+{
+	echo "⁉️ SUCCESS but some tests are broken 🤦" . PHP_EOL;
+
+	exit (2);
+}
+
+echo "✅ PASSED" . PHP_EOL;

+ 31 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Acl.php

@@ -0,0 +1,31 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+/**
+ * Shortcuts to often used access control privileges
+ */
+class Acl
+{
+	public const ACL_PRIVATE = 'private';
+
+	public const ACL_PUBLIC_READ = 'public-read';
+
+	public const ACL_PUBLIC_READ_WRITE = 'public-read-write';
+
+	public const ACL_AUTHENTICATED_READ = 'authenticated-read';
+
+	public const ACL_BUCKET_OWNER_READ = 'bucket-owner-read';
+
+	public const ACL_BUCKET_OWNER_FULL_CONTROL = 'bucket-owner-full-control';
+}

+ 447 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Configuration.php

@@ -0,0 +1,447 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+/**
+ * Holds the Amazon S3 confiugration credentials
+ */
+class Configuration
+{
+	/**
+	 * Access Key
+	 *
+	 * @var  string
+	 */
+	protected $access = '';
+
+	/**
+	 * Secret Key
+	 *
+	 * @var  string
+	 */
+	protected $secret = '';
+
+	/**
+	 * Security token. This is only required with temporary credentials provisioned by an EC2 instance.
+	 *
+	 * @var  string
+	 */
+	protected $token = '';
+
+	/**
+	 * Signature calculation method ('v2' or 'v4')
+	 *
+	 * @var  string
+	 */
+	protected $signatureMethod = 'v2';
+
+	/**
+	 * AWS region, used for v4 signatures
+	 *
+	 * @var  string
+	 */
+	protected $region = 'us-east-1';
+
+	/**
+	 * Should I use SSL (HTTPS) to communicate to Amazon S3?
+	 *
+	 * @var  bool
+	 */
+	protected $useSSL = true;
+
+	/**
+	 * Should I use SSL (HTTPS) to communicate to Amazon S3?
+	 *
+	 * @var  bool
+	 */
+	protected $useDualstackUrl = false;
+
+	/**
+	 * Should I use legacy, path-style access to the bucket? When it's turned off (default) we use virtual hosting style
+	 * paths which are RECOMMENDED BY AMAZON per http://docs.aws.amazon.com/AmazonS3/latest/API/APIRest.html
+	 *
+	 * @var  bool
+	 */
+	protected $useLegacyPathStyle = false;
+
+	/**
+	 * Amazon S3 endpoint. You can use a custom endpoint with v2 signatures to access third party services which offer
+	 * S3 compatibility, e.g. OwnCloud, Google Storage etc.
+	 *
+	 * @var  string
+	 */
+	protected $endpoint = 's3.amazonaws.com';
+
+	/**
+	 * Should I use an alternative date header format (D, d M Y H:i:s T instead of D, d M Y H:i:s O) for non-Amazon,
+	 * S3-compatible services?
+	 *
+	 * This is enabled by default.
+	 *
+	 * @var  bool
+	 */
+	protected $alternateDateHeaderFormat = true;
+
+	/**
+	 * Should I use the standard HTTP Date header instead of the X-Amz-Date header?
+	 *
+	 * @var  bool
+	 */
+	protected $useHTTPDateHeader = false;
+
+	/**
+	 * Should pre-signed URLs include the bucket name in the URL? Only applies to v4 signatures.
+	 *
+	 * Amazon S3 and most implementations need this turned off (default). LocalStack seems to not work properly with the
+	 * bucket name as a subdomain, hence the need for this flag.
+	 *
+	 * @var  bool
+	 */
+	protected $preSignedBucketInURL = false;
+
+	/**
+	 * Public constructor
+	 *
+	 * @param   string  $access           Amazon S3 Access Key
+	 * @param   string  $secret           Amazon S3 Secret Key
+	 * @param   string  $signatureMethod  Signature method (v2 or v4)
+	 * @param   string  $region           Region, only required for v4 signatures
+	 */
+	function __construct(string $access, string $secret, string $signatureMethod = 'v2', string $region = '')
+	{
+		$this->setAccess($access);
+		$this->setSecret($secret);
+		$this->setSignatureMethod($signatureMethod);
+		$this->setRegion($region);
+	}
+
+	/**
+	 * Get the Amazon access key
+	 *
+	 * @return  string
+	 */
+	public function getAccess(): string
+	{
+		return $this->access;
+	}
+
+	/**
+	 * Set the Amazon access key
+	 *
+	 * @param   string  $access  The access key to set
+	 *
+	 * @throws  Exception\InvalidAccessKey
+	 */
+	public function setAccess(string $access): void
+	{
+		if (empty($access))
+		{
+			throw new Exception\InvalidAccessKey;
+		}
+
+		$this->access = $access;
+	}
+
+	/**
+	 * Get the Amazon secret key
+	 *
+	 * @return string
+	 */
+	public function getSecret(): string
+	{
+		return $this->secret;
+	}
+
+	/**
+	 * Set the Amazon secret key
+	 *
+	 * @param   string  $secret  The secret key to set
+	 *
+	 * @throws  Exception\InvalidSecretKey
+	 */
+	public function setSecret(string $secret): void
+	{
+		if (empty($secret))
+		{
+			throw new Exception\InvalidSecretKey;
+		}
+
+		$this->secret = $secret;
+	}
+
+	/**
+	 * Return the security token. Only for temporary credentials provisioned through an EC2 instance.
+	 *
+	 * @return  string
+	 */
+	public function getToken(): string
+	{
+		return $this->token;
+	}
+
+	/**
+	 * Set the security token. Only for temporary credentials provisioned through an EC2 instance.
+	 *
+	 * @param   string  $token
+	 */
+	public function setToken(string $token): void
+	{
+		$this->token = $token;
+	}
+
+	/**
+	 * Get the signature method to use
+	 *
+	 * @return  string
+	 */
+	public function getSignatureMethod(): string
+	{
+		return $this->signatureMethod;
+	}
+
+	/**
+	 * Set the signature method to use
+	 *
+	 * @param   string  $signatureMethod  One of v2 or v4
+	 *
+	 * @throws  Exception\InvalidSignatureMethod
+	 */
+	public function setSignatureMethod(string $signatureMethod): void
+	{
+		$signatureMethod = strtolower($signatureMethod);
+		$signatureMethod = trim($signatureMethod);
+
+		if (!in_array($signatureMethod, ['v2', 'v4']))
+		{
+			throw new Exception\InvalidSignatureMethod;
+		}
+
+		$this->signatureMethod = $signatureMethod;
+
+		// If you switch to v2 signatures we unset the region.
+		if ($signatureMethod == 'v2')
+		{
+			$this->setRegion('');
+
+			/**
+			 * If we are using Amazon S3 proper (not a custom endpoint) we have to set path style access to false.
+			 * Amazon S3 does not support v2 signatures with path style access at all (it returns an error telling
+			 * us to use the virtual hosting endpoint BUCKETNAME.s3.amazonaws.com).
+			 */
+			if (strpos($this->endpoint, 'amazonaws.com') !== false)
+			{
+				$this->setUseLegacyPathStyle(false);
+			}
+
+		}
+	}
+
+	/**
+	 * Get the Amazon S3 region
+	 *
+	 * @return  string
+	 */
+	public function getRegion(): string
+	{
+		return $this->region;
+	}
+
+	/**
+	 * Set the Amazon S3 region
+	 *
+	 * @param   string  $region
+	 */
+	public function setRegion(string $region): void
+	{
+		/**
+		 * You can only leave the region empty if you're using v2 signatures. Anything else gets you an exception.
+		 */
+		if (empty($region) && ($this->signatureMethod == 'v4'))
+		{
+			throw new Exception\InvalidRegion;
+		}
+
+		/**
+		 * Setting a Chinese-looking region force-changes the endpoint but ONLY if you were using the original Amazon S3
+		 * endpoint. If you're using a custom endpoint and provide a region with 'cn-' in its name we don't override
+		 * your custom endpoint.
+		 */
+		if (($this->endpoint == 's3.amazonaws.com') && (substr($region, 0, 3) == 'cn-'))
+		{
+			$this->setEndpoint('amazonaws.com.cn');
+		}
+
+		$this->region = $region;
+	}
+
+	/**
+	 * Is the connection to be made over HTTPS?
+	 *
+	 * @return  bool
+	 */
+	public function isSSL(): bool
+	{
+		return $this->useSSL;
+	}
+
+	/**
+	 * Set the connection SSL preference
+	 *
+	 * @param   bool  $useSSL  True to use HTTPS
+	 */
+	public function setSSL(bool $useSSL): void
+	{
+		$this->useSSL = $useSSL ? true : false;
+	}
+
+	/**
+	 * Get the Amazon S3 endpoint
+	 *
+	 * @return  string
+	 */
+	public function getEndpoint(): string
+	{
+		return $this->endpoint;
+	}
+
+	/**
+	 * Set the Amazon S3 endpoint. Do NOT use a protocol
+	 *
+	 * @param   string  $endpoint  Custom endpoint, e.g. 's3.example.com' or 'www.example.com/s3api'
+	 */
+	public function setEndpoint(string $endpoint): void
+	{
+		if (stristr($endpoint, '://'))
+		{
+			throw new Exception\InvalidEndpoint;
+		}
+
+		/**
+		 * If you set a custom endpoint we have to switch to v2 signatures since our v4 implementation only supports
+		 * Amazon endpoints.
+		 */
+		if ((strpos($endpoint, 'amazonaws.com') === false))
+		{
+			$this->setSignatureMethod('v2');
+		}
+
+		$this->endpoint = $endpoint;
+	}
+
+	/**
+	 * Should I use legacy, path-style access to the bucket? You should only use it with custom endpoints. Amazon itself
+	 * is currently deprecating support for path-style access but has extended the migration date to an unknown
+	 * time https://aws.amazon.com/blogs/aws/amazon-s3-path-deprecation-plan-the-rest-of-the-story/
+	 *
+	 * @return  bool
+	 */
+	public function getUseLegacyPathStyle(): bool
+	{
+		return $this->useLegacyPathStyle;
+	}
+
+	/**
+	 * Set the flag for using legacy, path-style access to the bucket
+	 *
+	 * @param   bool  $useLegacyPathStyle
+	 */
+	public function setUseLegacyPathStyle(bool $useLegacyPathStyle): void
+	{
+		$this->useLegacyPathStyle = $useLegacyPathStyle;
+
+		/**
+		 * If we are using Amazon S3 proper (not a custom endpoint) we have to set path style access to false.
+		 * Amazon S3 does not support v2 signatures with path style access at all (it returns an error telling
+		 * us to use the virtual hosting endpoint BUCKETNAME.s3.amazonaws.com).
+		 */
+		if ((strpos($this->endpoint, 'amazonaws.com') !== false) && ($this->signatureMethod == 'v2'))
+		{
+			$this->useLegacyPathStyle = false;
+		}
+	}
+
+	/**
+	 * Should we use the dualstack URL (which will ship traffic over ipv6 in most cases). For more information on these
+	 * endpoints please read https://docs.aws.amazon.com/AmazonS3/latest/dev/dual-stack-endpoints.html
+	 *
+	 * @return  bool
+	 */
+	public function getDualstackUrl(): bool
+	{
+		return $this->useDualstackUrl;
+	}
+
+	/**
+	 * Set the flag for using legacy, path-style access to the bucket
+	 *
+	 * @param   bool  $useDualstackUrl
+	 */
+	public function setUseDualstackUrl(bool $useDualstackUrl): void
+	{
+		$this->useDualstackUrl = $useDualstackUrl;
+	}
+
+	/**
+	 * Get the flag for using an alternate date format for non-Amazon, S3-compatible services.
+	 *
+	 * @return bool
+	 */
+	public function getAlternateDateHeaderFormat(): bool
+	{
+		return $this->alternateDateHeaderFormat;
+	}
+
+	/**
+	 * Set the flag for using an alternate date format for non-Amazon, S3-compatible services.
+	 *
+	 * @param   bool  $alternateDateHeaderFormat
+	 *
+	 * @return  void
+	 */
+	public function setAlternateDateHeaderFormat(bool $alternateDateHeaderFormat): void
+	{
+		$this->alternateDateHeaderFormat = $alternateDateHeaderFormat;
+	}
+
+	/**
+	 * Get the flag indicating whether to use the HTTP Date header
+	 *
+	 * @return  bool  Flag indicating whether to use the HTTP Date header
+	 */
+	public function getUseHTTPDateHeader(): bool
+	{
+		return $this->useHTTPDateHeader;
+	}
+
+	/**
+	 * Set the flag indicating whether to use the HTTP Date header.
+	 *
+	 * @param   bool  $useHTTPDateHeader  Whether to use the HTTP Date header
+	 *
+	 * @return  void
+	 */
+	public function setUseHTTPDateHeader(bool $useHTTPDateHeader): void
+	{
+		$this->useHTTPDateHeader = $useHTTPDateHeader;
+	}
+
+	public function getPreSignedBucketInURL(): bool
+	{
+		return $this->preSignedBucketInURL;
+	}
+
+	public function setPreSignedBucketInURL(bool $preSignedBucketInURL): void
+	{
+		$this->preSignedBucketInURL = $preSignedBucketInURL;
+	}
+}

+ 983 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Connector.php

@@ -0,0 +1,983 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3;
+
+// Protection against direct access
+use Akeeba\S3\Exception\CannotDeleteFile;
+use Akeeba\S3\Exception\CannotGetBucket;
+use Akeeba\S3\Exception\CannotGetFile;
+use Akeeba\S3\Exception\CannotListBuckets;
+use Akeeba\S3\Exception\CannotOpenFileForWrite;
+use Akeeba\S3\Exception\CannotPutFile;
+use Akeeba\S3\Response\Error;
+
+defined('AKEEBAENGINE') || die();
+
+class Connector
+{
+	/**
+	 * Amazon S3 configuration object
+	 *
+	 * @var  Configuration
+	 */
+	private $configuration = null;
+
+	/**
+	 * Connector constructor.
+	 *
+	 * @param   Configuration  $configuration  The configuration object to use
+	 */
+	public function __construct(Configuration $configuration)
+	{
+		$this->configuration = $configuration;
+	}
+
+	/**
+	 * Put an object to Amazon S3, i.e. upload a file. If the object already exists it will be overwritten.
+	 *
+	 * @param   Input   $input           Input object
+	 * @param   string  $bucket          Bucket name. If you're using v4 signatures it MUST be on the region defined.
+	 * @param   string  $uri             Object URI. Think of it as the absolute path of the file in the bucket.
+	 * @param   string  $acl             ACL constant, by default the object is private (visible only to the uploading
+	 *                                   user)
+	 * @param   array   $requestHeaders  Array of request headers
+	 *
+	 * @return  void
+	 *
+	 * @throws CannotPutFile If the upload is not possible
+	 */
+	public function putObject(Input $input, string $bucket, string $uri, string $acl = Acl::ACL_PRIVATE, array $requestHeaders = []): void
+	{
+		$request = new Request('PUT', $bucket, $uri, $this->configuration);
+		$request->setInput($input);
+
+		// Custom request headers (Content-Type, Content-Disposition, Content-Encoding)
+		if (count($requestHeaders))
+		{
+			foreach ($requestHeaders as $h => $v)
+			{
+				if (strtolower(substr($h, 0, 6)) == 'x-amz-')
+				{
+					$request->setAmzHeader(strtolower($h), $v);
+				}
+				else
+				{
+					$request->setHeader($h, $v);
+				}
+			}
+		}
+
+		if (isset($requestHeaders['Content-Type']))
+		{
+			$input->setType($requestHeaders['Content-Type']);
+		}
+
+		if (($input->getSize() <= 0) || (($input->getInputType() == Input::INPUT_DATA) && (!strlen($input->getDataReference()))))
+		{
+			if (substr($uri, -1) !== '/')
+			{
+				throw new CannotPutFile('Missing input parameters', 0);
+			}
+		}
+
+		// We need to post with Content-Length and Content-Type, MD5 is optional
+		$request->setHeader('Content-Type', $input->getType());
+		$request->setHeader('Content-Length', $input->getSize());
+
+		if ($input->getMd5sum())
+		{
+			$request->setHeader('Content-MD5', $input->getMd5sum());
+		}
+
+		$request->setAmzHeader('x-amz-acl', $acl);
+
+		$response = $request->getResponse();
+
+		if ($response->code !== 200)
+		{
+			if (!$response->error->isError())
+			{
+				throw new CannotPutFile("Unexpected HTTP status {$response->code}", $response->code);
+			}
+
+			if (is_object($response->body) && ($response->body instanceof \SimpleXMLElement) && (strpos($input->getSize(), ',') === false))
+			{
+				// For some reason, trying to single part upload files on some hosts comes back with an inexplicable
+				// error from Amazon that we need to set Content-Length:5242880,5242880 instead of
+				// Content-Length:5242880 which is AGAINST Amazon's documentation. In this case we pass the header
+				// 'workaround-braindead-error-from-amazon' and retry. Uh, OK?
+				if (isset($response->body->CanonicalRequest))
+				{
+					$amazonsCanonicalRequest = (string) $response->body->CanonicalRequest;
+					$lines                   = explode("\n", $amazonsCanonicalRequest);
+
+					foreach ($lines as $line)
+					{
+						if (substr($line, 0, 15) != 'content-length:')
+						{
+							continue;
+						}
+
+						[$junk, $stupidAmazonDefinedContentLength] = explode(":", $line);
+
+						if (strpos($stupidAmazonDefinedContentLength, ',') !== false)
+						{
+							if (!isset($requestHeaders['workaround-braindead-error-from-amazon']))
+							{
+								$requestHeaders['workaround-braindead-error-from-amazon'] = 'you can\'t fix stupid';
+
+								$this->putObject($input, $bucket, $uri, $acl, $requestHeaders);
+
+								return;
+							}
+						}
+					}
+				}
+			}
+		}
+
+
+		if ($response->error->isError())
+		{
+			throw new CannotPutFile(
+				sprintf(__METHOD__ . "(): [%s] %s\n\nDebug info:\n%s", $response->error->getCode(), $response->error->getMessage(), print_r($response->body, true))
+			);
+		}
+	}
+
+	/**
+	 * Get (download) an object
+	 *
+	 * @param   string                $bucket  Bucket name
+	 * @param   string                $uri     Object URI
+	 * @param   string|resource|null  $saveTo  Filename or resource to write to
+	 * @param   int|null              $from    Start of the download range, null to download the entire object
+	 * @param   int|null              $to      End of the download range, null to download the entire object
+	 *
+	 * @return  string|null  No return if $saveTo is specified; data as string otherwise
+	 *
+	 */
+	public function getObject(string $bucket, string $uri, $saveTo = null, ?int $from = null, ?int $to = null): ?string
+	{
+		$request = new Request('GET', $bucket, $uri, $this->configuration);
+
+		$fp = null;
+
+		if (!is_resource($saveTo) && is_string($saveTo))
+		{
+			$fp = @fopen($saveTo, 'w');
+
+			if ($fp === false)
+			{
+				throw new CannotOpenFileForWrite($saveTo);
+			}
+		}
+
+		if (is_resource($saveTo))
+		{
+			$fp = $saveTo;
+		}
+
+		if (is_resource($fp))
+		{
+			$request->setFp($fp);
+		}
+
+		// Set the range header
+		if ((!empty($from) && !empty($to)) || (!is_null($from) && !empty($to)))
+		{
+			$request->setHeader('Range', "bytes=$from-$to");
+		}
+
+		$response = $request->getResponse(true);
+
+		if (!$response->error->isError() && (($response->code !== 200) && ($response->code !== 206)))
+		{
+			$response->error = new Error(
+				$response->code,
+				"Unexpected HTTP status {$response->code}"
+			);
+		}
+
+		if ($response->error->isError())
+		{
+			throw new CannotGetFile(
+				sprintf(
+					__METHOD__ . "({%s}, {%s}): [%s] %s\n\nDebug info:\n%s",
+					$bucket,
+					$uri,
+					$response->error->getCode(),
+					$response->error->getMessage(),
+					print_r($response->body, true)
+				)
+			);
+		}
+
+		if (!is_resource($fp))
+		{
+			return $response->body;
+		}
+
+		return null;
+	}
+
+	/**
+	 * Get information about an object.
+	 *
+	 * @param   string  $bucket  Bucket name
+	 * @param   string  $uri     Object URI
+	 *
+	 * @return  array  The headers returned by Amazon S3
+	 *
+	 * @throws  CannotGetFile  If the file does not exist
+	 * @see     https://docs.aws.amazon.com/AmazonS3/latest/API/API_HeadObject.html
+	 */
+	public function headObject(string $bucket, string $uri): array
+	{
+		$request = new Request('HEAD', $bucket, $uri, $this->configuration);
+
+		$response = $request->getResponse();
+
+		if (!$response->error->isError() && (($response->code !== 200) && ($response->code !== 206)))
+		{
+			$response->error = new Error(
+				$response->code,
+				"Unexpected HTTP status {$response->code}"
+			);
+		}
+
+		if ($response->error->isError())
+		{
+			throw new CannotGetFile(
+				sprintf(
+					__METHOD__ . "({%s}, {%s}): [%s] %s\n\nDebug info:\n%s",
+					$bucket,
+					$uri,
+					$response->error->getCode(),
+					$response->error->getMessage(),
+					print_r($response->body, true)
+				)
+			);
+		}
+
+		return $response->getHeaders();
+	}
+
+
+	/**
+	 * Delete an object
+	 *
+	 * @param   string  $bucket  Bucket name
+	 * @param   string  $uri     Object URI
+	 *
+	 * @return  void
+	 */
+	public function deleteObject(string $bucket, string $uri): void
+	{
+		$request  = new Request('DELETE', $bucket, $uri, $this->configuration);
+		$response = $request->getResponse();
+
+		if (!$response->error->isError() && ($response->code !== 204))
+		{
+			$response->error = new Error(
+				$response->code,
+				"Unexpected HTTP status {$response->code}"
+			);
+		}
+
+		if ($response->error->isError())
+		{
+			throw new CannotDeleteFile(
+				sprintf(
+					__METHOD__ . "({%s}, {%s}): [%s] %s",
+					$bucket,
+					$uri,
+					$response->error->getCode(),
+					$response->error->getMessage()
+				)
+			);
+		}
+	}
+
+	/**
+	 * Get a query string authenticated URL
+	 *
+	 * @param   string    $bucket    Bucket name
+	 * @param   string    $uri       Object URI
+	 * @param   int|null  $lifetime  Lifetime in seconds
+	 * @param   bool      $https     Use HTTPS ($hostBucket should be false for SSL verification)?
+	 *
+	 * @return  string
+	 */
+	public function getAuthenticatedURL(string $bucket, string $uri, ?int $lifetime = null, bool $https = false): string
+	{
+		// Get a request from the URI and bucket
+		$questionmarkPos = strpos($uri, '?');
+		$query           = '';
+
+		if ($questionmarkPos !== false)
+		{
+			$query = substr($uri, $questionmarkPos + 1);
+			$uri   = substr($uri, 0, $questionmarkPos);
+		}
+
+
+		/**
+		 * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+		 * !!!!             DO NOT TOUCH THIS CODE. YOU WILL BREAK PRE-SIGNED URLS WITH v4 SIGNATURES.              !!!!
+		 * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+		 *
+		 * The following two lines seem weird and possibly extraneous at first glance. However, they are VERY important.
+		 * If you remove them pre-signed URLs for v4 signatures will break! That's because pre-signed URLs with v4
+		 * signatures follow different rules than with v2 signatures.
+		 *
+		 * Authenticated (pre-signed) URLs are always made against the generic S3 region endpoint, not the bucket's
+		 * virtual-hosting-style domain name. The bucket is always the first component of the path.
+		 *
+		 * For example, given a bucket called foobar, and an object baz.txt in it, we are pre-signing the URL
+		 * https://s3-eu-west-1.amazonaws.com/foobar/baz.txt, not
+		 * https://foobar.s3-eu-west-1.amazonaws.com/foobar/baz.txt (as we'd be doing with v2 signatures).
+		 *
+		 * The problem is that the Request object needs to be created before we can convey the intent (regular request
+		 * or generation of a pre-signed URL). As a result its constructor creates the (immutable) request URI solely
+		 * based on whether the Configuration object's getUseLegacyPathStyle() returns false or not.
+		 *
+		 * Since we want to request URI to contain the bucket name we need to tell the Request object's constructor that
+		 * we are creating a Request object for path-style access, i.e. the useLegacyPathStyle flag in the Configuration
+		 * object is true. Naturally, the default behavior being virtual-hosting-style access to buckets, this flag is
+		 * most likely **false**.
+		 *
+		 * Therefore, we need to clone the Configuration object, set the flag to true and create a Request object using
+		 * the falsified Configuration object.
+		 *
+		 * Note that v2 signatures are not affected. In v2 we are always appending the bucket name to the path, despite
+		 * the fact that we include the bucket name in the domain name.
+		 *
+		 * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+		 * !!!!             DO NOT TOUCH THIS CODE. YOU WILL BREAK PRE-SIGNED URLS WITH v4 SIGNATURES.              !!!!
+		 * !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+		 */
+		$newConfig = clone $this->configuration;
+		$newConfig->setUseLegacyPathStyle(true);
+
+		// Create the request object.
+		$request = new Request('GET', $bucket, $uri, $newConfig);
+
+		if ($query)
+		{
+			parse_str($query, $parameters);
+
+			if (count($parameters))
+			{
+				foreach ($parameters as $k => $v)
+				{
+					$request->setParameter($k, $v);
+				}
+			}
+		}
+
+		// Get the signed URI from the Request object
+		return $request->getAuthenticatedURL($lifetime, $https);
+	}
+
+	/**
+	 * Get the location (region) of a bucket. You need this to use the V4 API on that bucket!
+	 *
+	 * @param   string  $bucket  Bucket name
+	 *
+	 * @return  string
+	 */
+	public function getBucketLocation(string $bucket): string
+	{
+		$request = new Request('GET', $bucket, '', $this->configuration);
+		$request->setParameter('location', null);
+
+		$response = $request->getResponse();
+
+		if (!$response->error->isError() && $response->code !== 200)
+		{
+			$response->error = new Error(
+				$response->code,
+				"Unexpected HTTP status {$response->code}"
+			);
+		}
+
+		if ($response->error->isError())
+		{
+			throw new CannotGetBucket(
+				sprintf(__METHOD__ . "(): [%s] %s", $response->error->getCode(), $response->error->getMessage())
+			);
+		}
+
+		$result = 'us-east-1';
+
+		if ($response->hasBody())
+		{
+			$result = (string) $response->body;
+		}
+
+		switch ($result)
+		{
+			// "EU" is an alias for 'eu-west-1', however the canonical location name you MUST use is 'eu-west-1'
+			case 'EU':
+			case 'eu':
+				$result = 'eu-west-1';
+				break;
+
+			// If the bucket location is 'us-east-1' you get an empty string. @#$%^&*()!!
+			case '':
+				$result = 'us-east-1';
+				break;
+		}
+
+		return $result;
+	}
+
+	/**
+	 * Get the contents of a bucket
+	 *
+	 * If maxKeys is null this method will loop through truncated result sets
+	 *
+	 * @param   string       $bucket                Bucket name
+	 * @param   string|null  $prefix                Prefix (directory)
+	 * @param   string|null  $marker                Marker (last file listed)
+	 * @param   int|null     $maxKeys               Maximum number of keys ("files" and "directories") to return
+	 * @param   string       $delimiter             Delimiter, typically "/"
+	 * @param   bool         $returnCommonPrefixes  Set to true to return CommonPrefixes
+	 *
+	 * @return  array
+	 */
+	public function getBucket(string $bucket, ?string $prefix = null, ?string $marker = null, ?int $maxKeys = null, string $delimiter = '/', bool $returnCommonPrefixes = false): array
+	{
+		$internalResult = $this->internalGetBucket($bucket, $prefix, $marker, $maxKeys, $delimiter, $returnCommonPrefixes);
+
+		/**
+		 * @var array   $objects
+		 * @var ?string $nextMarker
+		 */
+		extract($internalResult);
+		unset($internalResult);
+
+		// Loop through truncated results if maxKeys isn't specified or we don't have enough object records yet.
+		if ($nextMarker !== null && ($maxKeys === null || count($objects) < $maxKeys))
+		{
+			do
+			{
+				$internalResult = $this->internalGetBucket($bucket, $prefix, $nextMarker, $maxKeys, $delimiter, $returnCommonPrefixes);
+
+				$nextMarker = $internalResult['nextMarker'];
+				$objects    = array_merge($objects, $internalResult['objects']);
+
+				unset($internalResult);
+
+				// If the last call did not return a nextMarker I am done iterating.
+				if ($nextMarker === null)
+				{
+					break;
+				}
+
+				// If we have maxKeys AND the number of objects is at least this many I am done iterating.
+				if ($maxKeys !== null && count($objects) >= $maxKeys)
+				{
+					break;
+				}
+			} while (true);
+		}
+
+		if ($maxKeys !== null)
+		{
+			return array_splice($objects, 0, $maxKeys);
+		}
+
+		return $objects;
+	}
+
+	/**
+	 * Get a list of buckets
+	 *
+	 * @param   bool  $detailed  Returns detailed bucket list when true
+	 *
+	 * @return  array
+	 */
+	public function listBuckets(bool $detailed = false): array
+	{
+		// When listing buckets with the AWSv4 signature method we MUST set the region to us-east-1. Don't ask...
+		$configuration = clone $this->configuration;
+		$configuration->setRegion('us-east-1');
+
+		$request  = new Request('GET', '', '', $configuration);
+		$response = $request->getResponse();
+
+		if (!$response->error->isError() && (($response->code !== 200)))
+		{
+			$response->error = new Error(
+				$response->code,
+				"Unexpected HTTP status {$response->code}"
+			);
+		}
+
+		if ($response->error->isError())
+		{
+			throw new CannotListBuckets(
+				sprintf(__METHOD__ . "(): [%s] %s", $response->error->getCode(), $response->error->getMessage())
+			);
+		}
+
+		$results = [];
+
+		if (!isset($response->body->Buckets))
+		{
+			return $results;
+		}
+
+		if ($detailed)
+		{
+			if (isset($response->body->Owner, $response->body->Owner->ID, $response->body->Owner->DisplayName))
+			{
+				$results['owner'] = [
+					'id'   => (string) $response->body->Owner->ID,
+					'name' => (string) $response->body->Owner->DisplayName,
+				];
+			}
+
+			$results['buckets'] = [];
+
+			foreach ($response->body->Buckets->Bucket as $b)
+			{
+				$results['buckets'][] = [
+					'name' => (string) $b->Name,
+					'time' => strtotime((string) $b->CreationDate),
+				];
+			}
+		}
+		else
+		{
+			foreach ($response->body->Buckets->Bucket as $b)
+			{
+				$results[] = (string) $b->Name;
+			}
+		}
+
+		return $results;
+	}
+
+	/**
+	 * Start a multipart upload of an object
+	 *
+	 * @param   Input   $input           Input data
+	 * @param   string  $bucket          Bucket name
+	 * @param   string  $uri             Object URI
+	 * @param   string  $acl             ACL constant
+	 * @param   array   $requestHeaders  Array of request headers
+	 *
+	 * @return  string  The upload session ID (UploadId)
+	 */
+	public function startMultipart(Input $input, string $bucket, string $uri, string $acl = Acl::ACL_PRIVATE, array $requestHeaders = []): string
+	{
+		$request = new Request('POST', $bucket, $uri, $this->configuration);
+		$request->setParameter('uploads', '');
+
+		// Custom request headers (Content-Type, Content-Disposition, Content-Encoding)
+		if (is_array($requestHeaders))
+		{
+			foreach ($requestHeaders as $h => $v)
+			{
+				if (strtolower(substr($h, 0, 6)) == 'x-amz-')
+				{
+					$request->setAmzHeader(strtolower($h), $v);
+				}
+				else
+				{
+					$request->setHeader($h, $v);
+				}
+			}
+		}
+
+		$request->setAmzHeader('x-amz-acl', $acl);
+
+		if (isset($requestHeaders['Content-Type']))
+		{
+			$input->setType($requestHeaders['Content-Type']);
+		}
+
+		$request->setHeader('Content-Type', $input->getType());
+
+		$response = $request->getResponse();
+
+		if (!$response->error->isError() && ($response->code !== 200))
+		{
+			$response->error = new Error(
+				$response->code,
+				"Unexpected HTTP status {$response->code}"
+			);
+		}
+
+		if ($response->error->isError())
+		{
+			throw new CannotPutFile(
+				sprintf(
+					__METHOD__ . "(): [%s] %s\n\nDebug info:\n%s",
+					$response->error->getCode(),
+					$response->error->getMessage(),
+					print_r($response->body, true)
+				)
+			);
+		}
+
+		return (string) $response->body->UploadId;
+	}
+
+	/**
+	 * Uploads a part of a multipart object upload
+	 *
+	 * @param   Input   $input           Input data. You MUST specify the UploadID and PartNumber
+	 * @param   string  $bucket          Bucket name
+	 * @param   string  $uri             Object URI
+	 * @param   array   $requestHeaders  Array of request headers or content type as a string
+	 * @param   int     $chunkSize       Size of each upload chunk, in bytes. It cannot be less than 5242880 bytes (5Mb)
+	 *
+	 * @return  null|string  The ETag of the upload part of null if we have ran out of parts to upload
+	 */
+	public function uploadMultipart(Input $input, string $bucket, string $uri, array $requestHeaders = [], int $chunkSize = 5242880): ?string
+	{
+		if ($chunkSize < 5242880)
+		{
+			$chunkSize = 5242880;
+		}
+
+		// We need a valid UploadID and PartNumber
+		$UploadID   = $input->getUploadID();
+		$PartNumber = $input->getPartNumber();
+
+		if (empty($UploadID))
+		{
+			throw new CannotPutFile(
+				__METHOD__ . '(): No UploadID specified'
+			);
+		}
+
+		if (empty($PartNumber))
+		{
+			throw new CannotPutFile(
+				__METHOD__ . '(): No PartNumber specified'
+			);
+		}
+
+		//$UploadID   = urlencode($UploadID);
+		$PartNumber = (int) $PartNumber;
+
+		$request = new Request('PUT', $bucket, $uri, $this->configuration);
+		$request->setParameter('partNumber', $PartNumber);
+		$request->setParameter('uploadId', $UploadID);
+		$request->setInput($input);
+
+		// Full data length
+		$totalSize = $input->getSize();
+
+		// No Content-Type for multipart uploads
+		$input->setType(null);
+
+		// Calculate part offset
+		$partOffset = $chunkSize * ($PartNumber - 1);
+
+		if ($partOffset > $totalSize)
+		{
+			// This is to signify that we ran out of parts ;)
+			return null;
+		}
+
+		// How many parts are there?
+		$totalParts = floor($totalSize / $chunkSize);
+
+		if ($totalParts * $chunkSize < $totalSize)
+		{
+			$totalParts++;
+		}
+
+		// Calculate Content-Length
+		$size = $chunkSize;
+
+		if ($PartNumber >= $totalParts)
+		{
+			$size = $totalSize - ($PartNumber - 1) * $chunkSize;
+		}
+
+		if ($size <= 0)
+		{
+			// This is to signify that we ran out of parts ;)
+			return null;
+		}
+
+		$input->setSize($size);
+
+		switch ($input->getInputType())
+		{
+			case Input::INPUT_DATA:
+				$input->setData(substr($input->getData(), ($PartNumber - 1) * $chunkSize, $input->getSize()));
+				break;
+
+			case Input::INPUT_FILE:
+			case Input::INPUT_RESOURCE:
+				$fp = $input->getFp();
+				fseek($fp, ($PartNumber - 1) * $chunkSize);
+				break;
+		}
+
+		// Custom request headers (Content-Type, Content-Disposition, Content-Encoding)
+		if (is_array($requestHeaders))
+		{
+			foreach ($requestHeaders as $h => $v)
+			{
+				if (strtolower(substr($h, 0, 6)) == 'x-amz-')
+				{
+					$request->setAmzHeader(strtolower($h), $v);
+				}
+				else
+				{
+					$request->setHeader($h, $v);
+				}
+			}
+		}
+
+		$request->setHeader('Content-Length', $input->getSize());
+
+		if ($input->getInputType() === Input::INPUT_DATA)
+		{
+			$request->setHeader('Content-Type', "application/x-www-form-urlencoded");
+		}
+
+		$response = $request->getResponse();
+
+		if ($response->code !== 200)
+		{
+			if (!$response->error->isError())
+			{
+				$response->error = new Error(
+					$response->code,
+					"Unexpected HTTP status {$response->code}"
+				);
+			}
+
+			if (is_object($response->body) && ($response->body instanceof \SimpleXMLElement) && (strpos($input->getSize(), ',') === false))
+			{
+				// For some moronic reason, trying to multipart upload files on some hosts comes back with a crazy
+				// error from Amazon that we need to set Content-Length:5242880,5242880 instead of
+				// Content-Length:5242880 which is AGAINST Amazon's documentation. In this case we pass the header
+				// 'workaround-broken-content-length' and retry. Whatever.
+				if (isset($response->body->CanonicalRequest))
+				{
+					$amazonsCanonicalRequest = (string) $response->body->CanonicalRequest;
+					$lines                   = explode("\n", $amazonsCanonicalRequest);
+
+					foreach ($lines as $line)
+					{
+						if (substr($line, 0, 15) != 'content-length:')
+						{
+							continue;
+						}
+
+						[$junk, $stupidAmazonDefinedContentLength] = explode(":", $line);
+
+						if (strpos($stupidAmazonDefinedContentLength, ',') !== false)
+						{
+							if (!isset($requestHeaders['workaround-broken-content-length']))
+							{
+								$requestHeaders['workaround-broken-content-length'] = true;
+
+								// This is required to reset the input size to its default value. If you don't do that
+								// only one part will ever be uploaded. Oops!
+								$input->setSize(-1);
+
+								return $this->uploadMultipart($input, $bucket, $uri, $requestHeaders, $chunkSize);
+							}
+						}
+					}
+				}
+
+			}
+
+			throw new CannotPutFile(
+				sprintf(__METHOD__ . "(): [%s] %s\n\nDebug info:\n%s", $response->error->getCode(), $response->error->getMessage(), print_r($response->body, true))
+			);
+		}
+
+		// Return the ETag header
+		return $response->headers['hash'];
+	}
+
+	/**
+	 * Finalizes the multi-part upload. The $input object should contain two keys, etags an array of ETags of the
+	 * uploaded parts and UploadID the multipart upload ID.
+	 *
+	 * @param   Input   $input   The array of input elements
+	 * @param   string  $bucket  The bucket where the object is being stored
+	 * @param   string  $uri     The key (path) to the object
+	 *
+	 * @return  void
+	 */
+	public function finalizeMultipart(Input $input, string $bucket, string $uri): void
+	{
+		$etags    = $input->getEtags();
+		$UploadID = $input->getUploadID();
+
+		if (empty($etags))
+		{
+			throw new CannotPutFile(
+				__METHOD__ . '(): No ETags array specified'
+			);
+		}
+
+		if (empty($UploadID))
+		{
+			throw new CannotPutFile(
+				__METHOD__ . '(): No UploadID specified'
+			);
+		}
+
+		// Create the message
+		$message = "<CompleteMultipartUpload>\n";
+		$part    = 0;
+
+		foreach ($etags as $etag)
+		{
+			$part++;
+			$message .= "\t<Part>\n\t\t<PartNumber>$part</PartNumber>\n\t\t<ETag>\"$etag\"</ETag>\n\t</Part>\n";
+		}
+
+		$message .= "</CompleteMultipartUpload>";
+
+		// Get a request query
+		$reqInput = Input::createFromData($message);
+
+		$request = new Request('POST', $bucket, $uri, $this->configuration);
+		$request->setParameter('uploadId', $UploadID);
+		$request->setInput($reqInput);
+
+		// Do post
+		$request->setHeader('Content-Type', 'application/xml'); // Even though the Amazon API doc doesn't mention it, it's required... :(
+		$response = $request->getResponse();
+
+		if (!$response->error->isError() && ($response->code != 200))
+		{
+			$response->error = new Error(
+				$response->code,
+				"Unexpected HTTP status {$response->code}"
+			);
+		}
+
+		if ($response->error->isError())
+		{
+			if ($response->error->getCode() == 'RequestTimeout')
+			{
+				return;
+			}
+
+			throw new CannotPutFile(
+				sprintf(__METHOD__ . "(): [%s] %s\n\nDebug info:\n%s", $response->error->getCode(), $response->error->getMessage(), print_r($response->body, true))
+			);
+		}
+	}
+
+	/**
+	 * Returns the configuration object
+	 *
+	 * @return  Configuration
+	 */
+	public function getConfiguration(): Configuration
+	{
+		return $this->configuration;
+	}
+
+	private function internalGetBucket(string $bucket, ?string $prefix = null, ?string $marker = null, ?int $maxKeys = null, string $delimiter = '/', bool $returnCommonPrefixes = false): array
+	{
+		$request = new Request('GET', $bucket, '', $this->configuration);
+
+		if (!empty($prefix))
+		{
+			$request->setParameter('prefix', $prefix);
+		}
+
+		if (!empty($marker))
+		{
+			$request->setParameter('marker', $marker);
+		}
+
+		if (!empty($maxKeys))
+		{
+			$request->setParameter('max-keys', $maxKeys);
+		}
+
+		if (!empty($delimiter))
+		{
+			$request->setParameter('delimiter', $delimiter);
+		}
+
+		$response = $request->getResponse();
+
+		if (!$response->error->isError() && $response->code !== 200)
+		{
+			$response->error = new Error(
+				$response->code,
+				"Unexpected HTTP status {$response->code}"
+			);
+		}
+
+		if ($response->error->isError())
+		{
+			throw new CannotGetBucket(
+				sprintf(__METHOD__ . "(): [%s] %s", $response->error->getCode(), $response->error->getMessage())
+			);
+		}
+
+		$results = [
+			'objects'    => [],
+			'nextMarker' => null,
+		];
+
+		if ($response->hasBody() && isset($response->body->Contents))
+		{
+			foreach ($response->body->Contents as $c)
+			{
+				$results['objects'][(string) $c->Key] = [
+					'name' => (string) $c->Key,
+					'time' => strtotime((string) $c->LastModified),
+					'size' => (int) $c->Size,
+					'hash' => substr((string) $c->ETag, 1, -1),
+				];
+
+				$results['nextMarker'] = (string) $c->Key;
+			}
+		}
+
+		if ($returnCommonPrefixes && $response->hasBody() && isset($response->body->CommonPrefixes))
+		{
+			foreach ($response->body->CommonPrefixes as $c)
+			{
+				$results['objects'][(string) $c->Prefix] = ['prefix' => (string) $c->Prefix];
+			}
+		}
+
+		if ($response->hasBody() && isset($response->body->IsTruncated) &&
+			((string) $response->body->IsTruncated == 'false')
+		)
+		{
+			$results['nextMarker'] = null;
+
+			return $results;
+		}
+
+		if ($response->hasBody() && isset($response->body->NextMarker))
+		{
+			$results['nextMarker'] = (string) $response->body->NextMarker;
+		}
+
+		return $results;
+	}
+}

+ 19 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/CannotDeleteFile.php

@@ -0,0 +1,19 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+use RuntimeException;
+
+class CannotDeleteFile extends RuntimeException
+{
+}

+ 19 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/CannotGetBucket.php

@@ -0,0 +1,19 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+use RuntimeException;
+
+class CannotGetBucket extends RuntimeException
+{
+}

+ 19 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/CannotGetFile.php

@@ -0,0 +1,19 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+use RuntimeException;
+
+class CannotGetFile extends RuntimeException
+{
+}

+ 19 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/CannotListBuckets.php

@@ -0,0 +1,19 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+use RuntimeException;
+
+class CannotListBuckets extends RuntimeException
+{
+}

+ 27 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/CannotOpenFileForRead.php

@@ -0,0 +1,27 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+use RuntimeException;
+use Throwable;
+
+class CannotOpenFileForRead extends RuntimeException
+{
+	public function __construct(string $file = "", int $code = 0, ?Throwable $previous = null)
+	{
+		$message = "Cannot open $file for reading";
+
+		parent::__construct($message, $code, $previous);
+	}
+
+}

+ 27 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/CannotOpenFileForWrite.php

@@ -0,0 +1,27 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+use RuntimeException;
+use Throwable;
+
+class CannotOpenFileForWrite extends RuntimeException
+{
+	public function __construct(string $file = "", int $code = 0, ?Throwable $previous = null)
+	{
+		$message = "Cannot open $file for writing";
+
+		parent::__construct($message, $code, $previous);
+	}
+
+}

+ 19 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/CannotPutFile.php

@@ -0,0 +1,19 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+use RuntimeException;
+
+class CannotPutFile extends RuntimeException
+{
+}

+ 23 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/ConfigurationError.php

@@ -0,0 +1,23 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+use RuntimeException;
+
+/**
+ * Configuration error
+ */
+abstract class ConfigurationError extends RuntimeException
+{
+
+}

+ 32 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/InvalidAccessKey.php

@@ -0,0 +1,32 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+use Throwable;
+
+/**
+ * Invalid Amazon S3 access key
+ */
+class InvalidAccessKey extends ConfigurationError
+{
+	public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null)
+	{
+		if (empty($message))
+		{
+			$message = 'The Amazon S3 Access Key provided is invalid';
+		}
+
+		parent::__construct($message, $code, $previous);
+	}
+
+}

+ 33 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/InvalidBody.php

@@ -0,0 +1,33 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+use RuntimeException;
+use Throwable;
+
+/**
+ * Invalid response body type
+ */
+class InvalidBody extends RuntimeException
+{
+	public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null)
+	{
+		if (empty($message))
+		{
+			$message = 'Invalid response body type';
+		}
+
+		parent::__construct($message, $code, $previous);
+	}
+
+}

+ 32 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/InvalidEndpoint.php

@@ -0,0 +1,32 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+use Throwable;
+
+/**
+ * Invalid Amazon S3 endpoint
+ */
+class InvalidEndpoint extends ConfigurationError
+{
+	public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null)
+	{
+		if (empty($message))
+		{
+			$message = 'The custom S3 endpoint provided is invalid. Do NOT include the protocol (http:// or https://). Valid examples are s3.example.com and www.example.com/s3Api';
+		}
+
+		parent::__construct($message, $code, $previous);
+	}
+
+}

+ 30 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/InvalidFilePointer.php

@@ -0,0 +1,30 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+use InvalidArgumentException;
+use Throwable;
+
+class InvalidFilePointer extends InvalidArgumentException
+{
+	public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null)
+	{
+		if (empty($message))
+		{
+			$message = 'The specified file pointer is not a valid stream resource';
+		}
+
+		parent::__construct($message, $code, $previous);
+	}
+
+}

+ 32 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/InvalidRegion.php

@@ -0,0 +1,32 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+use Throwable;
+
+/**
+ * Invalid Amazon S3 region
+ */
+class InvalidRegion extends ConfigurationError
+{
+	public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null)
+	{
+		if (empty($message))
+		{
+			$message = 'The Amazon S3 region provided is invalid.';
+		}
+
+		parent::__construct($message, $code, $previous);
+	}
+
+}

+ 32 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/InvalidSecretKey.php

@@ -0,0 +1,32 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+use Throwable;
+
+/**
+ * Invalid Amazon S3 secret key
+ */
+class InvalidSecretKey extends ConfigurationError
+{
+	public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null)
+	{
+		if (empty($message))
+		{
+			$message = 'The Amazon S3 Secret Key provided is invalid';
+		}
+
+		parent::__construct($message, $code, $previous);
+	}
+
+}

+ 32 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/InvalidSignatureMethod.php

@@ -0,0 +1,32 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+use Throwable;
+
+/**
+ * Invalid Amazon S3 signature method
+ */
+class InvalidSignatureMethod extends ConfigurationError
+{
+	public function __construct(string $message = "", int $code = 0, ?Throwable $previous = null)
+	{
+		if (empty($message))
+		{
+			$message = 'The Amazon S3 signature method provided is invalid. Only v2 and v4 signatures are supported.';
+		}
+
+		parent::__construct($message, $code, $previous);
+	}
+
+}

+ 22 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Exception/PropertyNotFound.php

@@ -0,0 +1,22 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Exception;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+use LogicException;
+
+/**
+ * Invalid magic property name
+ */
+class PropertyNotFound extends LogicException
+{
+}

+ 766 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Input.php

@@ -0,0 +1,766 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+/**
+ * Defines an input source for PUT/POST requests to Amazon S3
+ */
+class Input
+{
+	/**
+	 * Input type: resource
+	 */
+	public const INPUT_RESOURCE = 1;
+
+	/**
+	 * Input type: file
+	 */
+	public const INPUT_FILE = 2;
+
+	/**
+	 * Input type: raw data
+	 */
+	public const INPUT_DATA = 3;
+
+	/**
+	 * File pointer, in case we have a resource
+	 *
+	 * @var  resource
+	 */
+	private $fp = null;
+
+	/**
+	 * Absolute filename to the file
+	 *
+	 * @var  string
+	 */
+	private $file = null;
+
+	/**
+	 * Data to upload, as a string
+	 *
+	 * @var  string
+	 */
+	private $data = null;
+
+	/**
+	 * Length of the data to upload
+	 *
+	 * @var  int
+	 */
+	private $size = -1;
+
+	/**
+	 * Content type (MIME type)
+	 *
+	 * @var  string|null
+	 */
+	private $type = '';
+
+	/**
+	 * MD5 sum of the data to upload, as base64 encoded string. If it's false no MD5 sum will be returned.
+	 *
+	 * @var  string|null
+	 */
+	private $md5sum = null;
+
+	/**
+	 * SHA-256 sum of the data to upload, as lowercase hex string.
+	 *
+	 * @var  string|null
+	 */
+	private $sha256 = null;
+
+	/**
+	 * The Upload Session ID used for multipart uploads
+	 *
+	 * @var  string|null
+	 */
+	private $UploadID = null;
+
+	/**
+	 * The part number used in multipart uploads
+	 *
+	 * @var  int|null
+	 */
+	private $PartNumber = null;
+
+	/**
+	 * The list of ETags used when finalising a multipart upload
+	 *
+	 * @var  string[]
+	 */
+	private $etags = [];
+
+	/**
+	 * Create an input object from a file (also: any valid URL wrapper)
+	 *
+	 * @param   string       $file       Absolute file path or any valid URL fopen() wrapper
+	 * @param   null|string  $md5sum     The MD5 sum. null to auto calculate, empty string to never calculate.
+	 * @param   null|string  $sha256sum  The SHA256 sum. null to auto calculate.
+	 *
+	 * @return  Input
+	 */
+	public static function createFromFile(string $file, ?string $md5sum = null, ?string $sha256sum = null): self
+	{
+		$input = new Input();
+
+		$input->setFile($file);
+		$input->setMd5sum($md5sum);
+		$input->setSha256($sha256sum);
+
+		return $input;
+	}
+
+	/**
+	 * Create an input object from a stream resource / file pointer.
+	 *
+	 * Please note that the contentLength cannot be calculated automatically unless you have a seekable stream resource.
+	 *
+	 * @param   resource     $resource       The file pointer or stream resource
+	 * @param   int          $contentLength  The length of the content in bytes. Set to -1 for auto calculation.
+	 * @param   null|string  $md5sum         The MD5 sum. null to auto calculate, empty string to never calculate.
+	 * @param   null|string  $sha256sum      The SHA256 sum. null to auto calculate.
+	 *
+	 * @return  Input
+	 */
+	public static function createFromResource(&$resource, int $contentLength, ?string $md5sum = null, ?string $sha256sum = null): self
+	{
+		$input = new Input();
+
+		$input->setFp($resource);
+		$input->setSize($contentLength);
+		$input->setMd5sum($md5sum);
+		$input->setSha256($sha256sum);
+
+		return $input;
+	}
+
+	/**
+	 * Create an input object from raw data.
+	 *
+	 * Please bear in mind that the data is being duplicated in memory. Therefore you'll need at least 2xstrlen($data)
+	 * of free memory when you are using this method. You can instantiate an object and use assignData to work around
+	 * this limitation when handling large amounts of data which may cause memory outages (typically: over 10Mb).
+	 *
+	 * @param   string       $data       The data to use.
+	 * @param   null|string  $md5sum     The MD5 sum. null to auto calculate, empty string to never calculate.
+	 * @param   null|string  $sha256sum  The SHA256 sum. null to auto calculate.
+	 *
+	 * @return  Input
+	 */
+	public static function createFromData(string &$data, ?string $md5sum = null, ?string $sha256sum = null): self
+	{
+		$input = new Input();
+
+		$input->setData($data);
+		$input->setMd5sum($md5sum);
+		$input->setSha256($sha256sum);
+
+		return $input;
+	}
+
+	/**
+	 * Destructor.
+	 */
+	function __destruct()
+	{
+		if (is_resource($this->fp))
+		{
+			try
+			{
+				@fclose($this->fp);
+			}
+			catch (\Throwable $e)
+			{
+			}
+		}
+	}
+
+	/**
+	 * Returns the input type (resource, file or data)
+	 *
+	 * @return  int
+	 */
+	public function getInputType(): int
+	{
+		if (!empty($this->file))
+		{
+			return self::INPUT_FILE;
+		}
+
+		if (!empty($this->fp))
+		{
+			return self::INPUT_RESOURCE;
+		}
+
+		return self::INPUT_DATA;
+	}
+
+	/**
+	 * Return the file pointer to the data, or null if this is not a resource input
+	 *
+	 * @return  resource|null
+	 */
+	public function getFp()
+	{
+		if (!is_resource($this->fp))
+		{
+			return null;
+		}
+
+		return $this->fp;
+	}
+
+	/**
+	 * Set the file pointer (or, generally, stream resource)
+	 *
+	 * @param   resource  $fp
+	 */
+	public function setFp($fp): void
+	{
+		if (!is_resource($fp))
+		{
+			throw new Exception\InvalidFilePointer('$fp is not a file resource');
+		}
+
+		$this->fp = $fp;
+	}
+
+	/**
+	 * Get the absolute path to the input file, or null if this is not a file input
+	 *
+	 * @return  string|null
+	 */
+	public function getFile(): ?string
+	{
+		if (empty($this->file))
+		{
+			return null;
+		}
+
+		return $this->file;
+	}
+
+	/**
+	 * Set the absolute path to the input file
+	 *
+	 * @param   string  $file
+	 */
+	public function setFile(string $file): void
+	{
+		$this->file = $file;
+		$this->data = null;
+
+		if (is_resource($this->fp))
+		{
+			try
+			{
+				@fclose($this->fp);
+			}
+			catch (\Throwable $e)
+			{
+			}
+		}
+
+		$this->fp = @fopen($file, 'r');
+
+		if ($this->fp === false)
+		{
+			throw new Exception\CannotOpenFileForRead($file);
+		}
+	}
+
+	/**
+	 * Return the raw input data, or null if this is a file or stream input
+	 *
+	 * @return  string|null
+	 */
+	public function getData(): ?string
+	{
+		if (empty($this->data) && ($this->getInputType() != self::INPUT_DATA))
+		{
+			return null;
+		}
+
+		return $this->data;
+	}
+
+	/**
+	 * Set the raw input data
+	 *
+	 * @param   string  $data
+	 */
+	public function setData(string $data): void
+	{
+		$this->data = $data;
+
+		if (is_resource($this->fp))
+		{
+			try
+			{
+				@fclose($this->fp);
+			}
+			catch (\Throwable $e)
+			{
+			}
+		}
+
+		$this->file = null;
+		$this->fp   = null;
+	}
+
+	/**
+	 * Return a reference to the raw input data
+	 *
+	 * @return  string|null
+	 */
+	public function &getDataReference(): ?string
+	{
+		if (empty($this->data) && ($this->getInputType() != self::INPUT_DATA))
+		{
+			$this->data = null;
+		}
+
+		return $this->data;
+	}
+
+	/**
+	 * Set the raw input data by doing an assignment instead of memory copy. While this conserves memory you cannot use
+	 * this with hardcoded strings, method results etc without going through a variable first.
+	 *
+	 * @param   string  $data
+	 */
+	public function assignData(string &$data): void
+	{
+		$this->data = $data;
+
+		if (is_resource($this->fp))
+		{
+			try
+			{
+				@fclose($this->fp);
+			}
+			catch (\Throwable $e)
+			{
+			}
+		}
+
+		$this->file = null;
+		$this->fp   = null;
+	}
+
+	/**
+	 * Returns the size of the data to be uploaded, in bytes. If it's not already specified it will try to guess.
+	 *
+	 * @return  int
+	 */
+	public function getSize(): int
+	{
+		if ($this->size < 0)
+		{
+			$this->size = $this->getInputSize();
+		}
+
+		return $this->size;
+	}
+
+	/**
+	 * Set the size of the data to be uploaded.
+	 *
+	 * @param   int  $size
+	 */
+	public function setSize(int $size)
+	{
+		$this->size = $size;
+	}
+
+	/**
+	 * Get the MIME type of the data
+	 *
+	 * @return  string|null
+	 */
+	public function getType(): ?string
+	{
+		if (empty($this->type))
+		{
+			$this->type = 'application/octet-stream';
+
+			if ($this->getInputType() == self::INPUT_FILE)
+			{
+				$this->type = $this->getMimeType($this->file);
+			}
+		}
+
+		return $this->type;
+	}
+
+	/**
+	 * Set the MIME type of the data
+	 *
+	 * @param   string|null  $type
+	 */
+	public function setType(?string $type)
+	{
+		$this->type = $type;
+	}
+
+	/**
+	 * Get the MD5 sum of the content
+	 *
+	 * @return  null|string
+	 */
+	public function getMd5sum(): ?string
+	{
+		if ($this->md5sum === '')
+		{
+			return null;
+		}
+
+		if (is_null($this->md5sum))
+		{
+			$this->md5sum = $this->calculateMd5();
+		}
+
+		return $this->md5sum;
+	}
+
+	/**
+	 * Set the MD5 sum of the content as a base64 encoded string of the raw MD5 binary value.
+	 *
+	 * WARNING: Do not set a binary MD5 sum or a hex-encoded MD5 sum, it will result in an invalid signature error!
+	 *
+	 * Set to null to automatically calculate it from the raw data. Set to an empty string to force it to never be
+	 * calculated and no value for it set either.
+	 *
+	 * @param   string|null  $md5sum
+	 */
+	public function setMd5sum(?string $md5sum): void
+	{
+		$this->md5sum = $md5sum;
+	}
+
+	/**
+	 * Get the SHA-256 hash of the content
+	 *
+	 * @return  string
+	 */
+	public function getSha256(): string
+	{
+		if (empty($this->sha256))
+		{
+			$this->sha256 = $this->calculateSha256();
+		}
+
+		return $this->sha256;
+	}
+
+	/**
+	 * Set the SHA-256 sum of the content. It must be a lowercase hexadecimal encoded string.
+	 *
+	 * Set to null to automatically calculate it from the raw data.
+	 *
+	 * @param   string|null  $sha256
+	 */
+	public function setSha256(?string $sha256): void
+	{
+		$this->sha256 = is_null($sha256) ? null : strtolower($sha256);
+	}
+
+	/**
+	 * Get the Upload Session ID for multipart uploads
+	 *
+	 * @return  string|null
+	 */
+	public function getUploadID(): ?string
+	{
+		return $this->UploadID;
+	}
+
+	/**
+	 * Set the Upload Session ID for multipart uploads
+	 *
+	 * @param   string|null  $UploadID
+	 */
+	public function setUploadID(?string $UploadID): void
+	{
+		$this->UploadID = $UploadID;
+	}
+
+	/**
+	 * Get the part number for multipart uploads.
+	 *
+	 * Returns null if the part number has not been set yet.
+	 *
+	 * @return  int|null
+	 */
+	public function getPartNumber(): ?int
+	{
+		return $this->PartNumber;
+	}
+
+	/**
+	 * Set the part number for multipart uploads
+	 *
+	 * @param   int  $PartNumber
+	 */
+	public function setPartNumber(int $PartNumber): void
+	{
+		// Clamp the part number to integers greater than zero.
+		$this->PartNumber = max(1, (int) $PartNumber);
+	}
+
+	/**
+	 * Get the list of ETags for multipart uploads
+	 *
+	 * @return  string[]
+	 */
+	public function getEtags(): array
+	{
+		return $this->etags;
+	}
+
+	/**
+	 * Set the list of ETags for multipart uploads
+	 *
+	 * @param   string[]  $etags
+	 */
+	public function setEtags(array $etags): void
+	{
+		$this->etags = $etags;
+	}
+
+	/**
+	 * Calculates the upload size from the input source. For data it's the entire raw string length. For a file resource
+	 * it's the entire file's length. For seekable stream resources it's the remaining data from the current seek
+	 * position to EOF.
+	 *
+	 * WARNING: You should never try to specify files or resources over 2Gb minus 1 byte otherwise 32-bit versions of
+	 * PHP (anything except Linux x64 builds) will fail in unpredictable ways: the internal int representation in PHP
+	 * depends on the target platform and is typically a signed 32-bit integer.
+	 *
+	 * @return  int
+	 */
+	private function getInputSize(): int
+	{
+		switch ($this->getInputType())
+		{
+			case self::INPUT_DATA:
+				return function_exists('mb_strlen') ? mb_strlen($this->data ?? '', '8bit') : strlen($this->data ?? '');
+				break;
+
+			case self::INPUT_FILE:
+				clearstatcache(true, $this->file);
+
+				$filesize = @filesize($this->file);
+
+				return ($filesize === false) ? 0 : $filesize;
+				break;
+
+			case self::INPUT_RESOURCE:
+				$meta = stream_get_meta_data($this->fp);
+
+				if ($meta['seekable'])
+				{
+					$pos    = ftell($this->fp);
+					$endPos = fseek($this->fp, 0, SEEK_END);
+					fseek($this->fp, $pos, SEEK_SET);
+
+					return $endPos - $pos + 1;
+				}
+
+				break;
+		}
+
+		return 0;
+	}
+
+	/**
+	 * Get the MIME type of a file
+	 *
+	 * @param   string  $file  The absolute path to the file for which we want to get the MIME type
+	 *
+	 * @return  string  The MIME type of the file
+	 */
+	private function getMimeType(string $file): string
+	{
+		$type = false;
+
+		// Fileinfo documentation says fileinfo_open() will use the
+		// MAGIC env var for the magic file
+		if (extension_loaded('fileinfo') && isset($_ENV['MAGIC']) &&
+			($finfo = finfo_open(FILEINFO_MIME, $_ENV['MAGIC'])) !== false
+		)
+		{
+			if (($type = finfo_file($finfo, $file)) !== false)
+			{
+				// Remove the charset and grab the last content-type
+				$type = explode(' ', str_replace('; charset=', ';charset=', $type));
+				$type = array_pop($type);
+				$type = explode(';', $type);
+				$type = trim(array_shift($type));
+			}
+
+			finfo_close($finfo);
+		}
+		elseif (function_exists('mime_content_type'))
+		{
+			$type = trim(mime_content_type($file));
+		}
+
+		if ($type !== false && strlen($type) > 0)
+		{
+			return $type;
+		}
+
+		// Otherwise do it the old fashioned way
+		static $exts = [
+			'jpg'  => 'image/jpeg',
+			'gif'  => 'image/gif',
+			'png'  => 'image/png',
+			'tif'  => 'image/tiff',
+			'tiff' => 'image/tiff',
+			'ico'  => 'image/x-icon',
+			'swf'  => 'application/x-shockwave-flash',
+			'pdf'  => 'application/pdf',
+			'zip'  => 'application/zip',
+			'gz'   => 'application/x-gzip',
+			'tar'  => 'application/x-tar',
+			'bz'   => 'application/x-bzip',
+			'bz2'  => 'application/x-bzip2',
+			'txt'  => 'text/plain',
+			'asc'  => 'text/plain',
+			'htm'  => 'text/html',
+			'html' => 'text/html',
+			'css'  => 'text/css',
+			'js'   => 'text/javascript',
+			'xml'  => 'text/xml',
+			'xsl'  => 'application/xsl+xml',
+			'ogg'  => 'application/ogg',
+			'mp3'  => 'audio/mpeg',
+			'wav'  => 'audio/x-wav',
+			'avi'  => 'video/x-msvideo',
+			'mpg'  => 'video/mpeg',
+			'mpeg' => 'video/mpeg',
+			'mov'  => 'video/quicktime',
+			'flv'  => 'video/x-flv',
+			'php'  => 'text/x-php',
+		];
+
+		$ext = strtolower(pathInfo($file, PATHINFO_EXTENSION));
+
+		return $exts[$ext] ?? 'application/octet-stream';
+	}
+
+	/**
+	 * Calculate the MD5 sum of the input data
+	 *
+	 * @return  string  Base-64 encoded MD5 sum
+	 */
+	private function calculateMd5(): string
+	{
+		switch ($this->getInputType())
+		{
+			case self::INPUT_DATA:
+				return base64_encode(
+					function_exists('hash')
+						? hash('md5', $this->data, true)
+						: md5($this->data, true)
+				);
+				break;
+
+			case self::INPUT_FILE:
+				return base64_encode(
+					function_exists('hash_file')
+						? hash_file('md5', $this->file, true)
+						: md5_file($this->file, true)
+				);
+				break;
+
+			case self::INPUT_RESOURCE:
+				$ctx   = hash_init('md5');
+				$pos   = ftell($this->fp);
+				$size  = $this->getSize();
+				$done  = 0;
+				$batch = min(1048576, $size);
+
+				while ($done < $size)
+				{
+					$toRead = min($batch, $done - $size);
+					$data   = @fread($this->fp, $toRead);
+					hash_update($ctx, $data);
+					unset($data);
+				}
+
+				fseek($this->fp, $pos, SEEK_SET);
+
+				return base64_encode(hash_final($ctx, true));
+
+				break;
+		}
+
+		return '';
+	}
+
+	/**
+	 * Calcualte the SHA256 data of the input data
+	 *
+	 * @return  string  Lowercase hex representation of the SHA-256 sum
+	 */
+	private function calculateSha256(): string
+	{
+		$inputType = $this->getInputType();
+		switch ($inputType)
+		{
+			case self::INPUT_DATA:
+				return hash('sha256', $this->data, false);
+				break;
+
+			case self::INPUT_FILE:
+			case self::INPUT_RESOURCE:
+				if ($inputType == self::INPUT_FILE)
+				{
+					$filesize = @filesize($this->file);
+					$fPos     = @ftell($this->fp);
+
+					if (($filesize == $this->getSize()) && ($fPos === 0))
+					{
+						return hash_file('sha256', $this->file, false);
+					}
+				}
+
+				$ctx   = hash_init('sha256');
+				$pos   = ftell($this->fp);
+				$size  = $this->getSize();
+				$done  = 0;
+				$batch = min(1048576, $size);
+
+				while ($done < $size)
+				{
+					$toRead = min($batch, $size - $done);
+					$data   = @fread($this->fp, $toRead);
+					$done   += $toRead;
+					hash_update($ctx, $data);
+					unset($data);
+				}
+
+				fseek($this->fp, $pos, SEEK_SET);
+
+				return hash_final($ctx, false);
+
+				break;
+		}
+
+		return '';
+	}
+}

+ 823 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Request.php

@@ -0,0 +1,823 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3;
+
+use Akeeba\S3\Response\Error;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+
+class Request
+{
+	/**
+	 * The HTTP verb to use
+	 *
+	 * @var  string
+	 */
+	private $verb = 'GET';
+
+	/**
+	 * The bucket we are using
+	 *
+	 * @var  string
+	 */
+	private $bucket = '';
+
+	/**
+	 * The object URI, relative to the bucket's root
+	 *
+	 * @var  string
+	 */
+	private $uri = '';
+
+	/**
+	 * The remote resource we are querying
+	 *
+	 * @var  string
+	 */
+	private $resource = '';
+
+	/**
+	 * Query string parameters
+	 *
+	 * @var  array
+	 */
+	private $parameters = [];
+
+	/**
+	 * Amazon-specific headers to pass to the request
+	 *
+	 * @var  array
+	 */
+	private $amzHeaders = [];
+
+	/**
+	 * Regular HTTP headers to send in the request
+	 *
+	 * @var  array
+	 */
+	private $headers = [
+		'Host'         => '',
+		'Date'         => '',
+		'Content-MD5'  => '',
+		'Content-Type' => '',
+	];
+
+	/**
+	 * Input data for the request
+	 *
+	 * @var  Input
+	 */
+	private $input = null;
+
+	/**
+	 * The file resource we are writing data to
+	 *
+	 * @var  resource|null
+	 */
+	private $fp = null;
+
+	/**
+	 * The Amazon S3 configuration object
+	 *
+	 * @var Configuration
+	 */
+	private $configuration = null;
+
+	/**
+	 * The response object
+	 *
+	 * @var  Response
+	 */
+	private $response = null;
+
+	/**
+	 * The location of the CA certificate cache. It can be a file or a directory. If it's not specified, the location
+	 * set in AKEEBA_CACERT_PEM will be used
+	 *
+	 * @var  string|null
+	 */
+	private $caCertLocation = null;
+
+	/**
+	 * Constructor
+	 *
+	 * @param   string         $verb           HTTP verb, e.g. 'POST'
+	 * @param   string         $bucket         Bucket name, e.g. 'example-bucket'
+	 * @param   string         $uri            Object URI
+	 * @param   Configuration  $configuration  The Amazon S3 configuration object to use
+	 *
+	 * @return  void
+	 */
+	function __construct(string $verb, string $bucket, string $uri, Configuration $configuration)
+	{
+		$this->verb          = $verb;
+		$this->bucket        = $bucket;
+		$this->uri           = '/';
+		$this->configuration = $configuration;
+
+		if (!empty($uri))
+		{
+			$this->uri = '/' . str_replace('%2F', '/', rawurlencode($uri));
+		}
+
+		$this->headers['Host'] = $this->getHostName($configuration, $this->bucket);
+		$this->resource        = $this->uri;
+
+		if (($this->bucket !== '') && $configuration->getUseLegacyPathStyle())
+		{
+			$this->resource = '/' . $this->bucket . $this->uri;
+
+			$this->uri = $this->resource;
+		}
+
+		// The date must always be added as a header
+		$this->headers['Date'] = gmdate('D, d M Y H:i:s O');
+
+		// S3-"compatible" services use a different date format. Because why not?
+		if ($this->configuration->getAlternateDateHeaderFormat() && strpos($this->headers['Host'], '.amazonaws.com') === false)
+		{
+			$this->headers['Date'] = gmdate('D, d M Y H:i:s T');
+		}
+
+		// If there is a security token we need to set up the X-Amz-Security-Token header
+		$token = $this->configuration->getToken();
+
+		if (!empty($token))
+		{
+			$this->setAmzHeader('x-amz-security-token', $token);
+		}
+
+		// Initialize the response object
+		$this->response = new Response();
+	}
+
+	/**
+	 * Get the input object
+	 *
+	 * @return  Input|null
+	 */
+	public function getInput(): ?Input
+	{
+		return $this->input;
+	}
+
+	/**
+	 * Set the input object
+	 *
+	 * @param   Input  $input
+	 *
+	 * @return  void
+	 */
+	public function setInput(Input $input): void
+	{
+		$this->input = $input;
+	}
+
+	/**
+	 * Set a request parameter
+	 *
+	 * @param   string       $key    The parameter name
+	 * @param   string|null  $value  The parameter value
+	 *
+	 * @return  void
+	 */
+	public function setParameter(string $key, ?string $value): void
+	{
+		$this->parameters[$key] = $value;
+	}
+
+	/**
+	 * Set a request header
+	 *
+	 * @param   string  $key    The header name
+	 * @param   string  $value  The header value
+	 *
+	 * @return  void
+	 */
+	public function setHeader(string $key, string $value): void
+	{
+		$this->headers[$key] = $value;
+	}
+
+	/**
+	 * Set an x-amz-meta-* header
+	 *
+	 * @param   string  $key    The header name
+	 * @param   string  $value  The header value
+	 *
+	 * @return  void
+	 */
+	public function setAmzHeader(string $key, string $value): void
+	{
+		$this->amzHeaders[$key] = $value;
+	}
+
+	/**
+	 * Get the HTTP verb of this request
+	 *
+	 * @return  string
+	 */
+	public function getVerb(): string
+	{
+		return $this->verb;
+	}
+
+	/**
+	 * Get the S3 bucket's name
+	 *
+	 * @return  string
+	 */
+	public function getBucket(): string
+	{
+		return $this->bucket;
+	}
+
+	/**
+	 * Get the absolute URI of the resource we're accessing
+	 *
+	 * @return  string
+	 */
+	public function getResource(): string
+	{
+		return $this->resource;
+	}
+
+	/**
+	 * Get the parameters array
+	 *
+	 * @return  array
+	 */
+	public function getParameters(): array
+	{
+		return $this->parameters;
+	}
+
+	/**
+	 * Get the Amazon headers array
+	 *
+	 * @return  array
+	 */
+	public function getAmzHeaders(): array
+	{
+		return $this->amzHeaders;
+	}
+
+	/**
+	 * Get the other headers array
+	 *
+	 * @return  array
+	 */
+	public function getHeaders(): array
+	{
+		return $this->headers;
+	}
+
+	/**
+	 * Get a reference to the Amazon configuration object
+	 *
+	 * @return  Configuration
+	 */
+	public function getConfiguration(): Configuration
+	{
+		return $this->configuration;
+	}
+
+	/**
+	 * Get the file pointer resource (for PUT and POST requests)
+	 *
+	 * @return  resource|null
+	 */
+	public function &getFp()
+	{
+		return $this->fp;
+	}
+
+	/**
+	 * Set the data resource as a file pointer
+	 *
+	 * @param   resource  $fp
+	 */
+	public function setFp($fp): void
+	{
+		$this->fp = $fp;
+	}
+
+	/**
+	 * Get the certificate authority location
+	 *
+	 * @return  string|null
+	 */
+	public function getCaCertLocation(): ?string
+	{
+		if (!empty($this->caCertLocation))
+		{
+			return $this->caCertLocation;
+		}
+
+		if (defined('AKEEBA_CACERT_PEM'))
+		{
+			return AKEEBA_CACERT_PEM;
+		}
+
+		return null;
+	}
+
+	/**
+	 * @param   null|string  $caCertLocation
+	 */
+	public function setCaCertLocation(?string $caCertLocation): void
+	{
+		if (empty($caCertLocation))
+		{
+			$caCertLocation = null;
+		}
+
+		if (!is_null($caCertLocation) && !is_file($caCertLocation) && !is_dir($caCertLocation))
+		{
+			$caCertLocation = null;
+		}
+
+		$this->caCertLocation = $caCertLocation;
+	}
+
+	/**
+	 * Get a pre-signed URL for the request.
+	 *
+	 * Typically used to pre-sign GET requests to objects, i.e. give shareable pre-authorized URLs for downloading
+	 * private or otherwise inaccessible files from S3.
+	 *
+	 * @param   int|null  $lifetime  Lifetime in seconds
+	 * @param   bool      $https     Use HTTPS ($hostBucket should be false for SSL verification)?
+	 *
+	 * @return  string  The authenticated URL, complete with signature
+	 */
+	public function getAuthenticatedURL(?int $lifetime = null, bool $https = false): string
+	{
+		$this->processParametersIntoResource();
+		$signer = Signature::getSignatureObject($this, $this->configuration->getSignatureMethod());
+
+		return $signer->getAuthenticatedURL($lifetime, $https);
+	}
+
+	/**
+	 * Get the S3 response
+	 *
+	 * @return  Response
+	 */
+	public function getResponse(bool $rawResponse = false): Response
+	{
+		$this->processParametersIntoResource();
+
+		$schema = 'http://';
+
+		if ($this->configuration->isSSL())
+		{
+			$schema = 'https://';
+		}
+
+		// Very special case. IF the URI ends in /?location AND the region is us-east-1 (Host is
+		// s3-external-1.amazonaws.com) THEN the host MUST become s3.amazonaws.com for the request to work. This is case
+		// of us not knowing the region of the bucket, therefore having to use a special endpoint which lets us query
+		// the region of the bucket without knowing its region. See
+		// http://stackoverflow.com/questions/27091816/retrieve-buckets-objects-without-knowing-buckets-region-with-aws-s3-rest-api
+		if ((substr($this->uri, -10) == '/?location') && ($this->headers['Host'] == 's3-external-1.amazonaws.com'))
+		{
+			$this->headers['Host'] = 's3.amazonaws.com';
+		}
+
+		$url = $schema . $this->headers['Host'] . $this->uri;
+
+		// Basic setup
+		$curl = curl_init();
+		curl_setopt($curl, CURLOPT_USERAGENT, 'AkeebaBackupProfessional/S3PostProcessor');
+
+		if ($this->configuration->isSSL())
+		{
+			// Set the CA certificate cache location
+			$caCert = $this->getCaCertLocation();
+
+			if (!empty($caCert))
+			{
+				if (is_dir($caCert))
+				{
+					@curl_setopt($curl, CURLOPT_CAPATH, $caCert);
+				}
+				else
+				{
+					@curl_setopt($curl, CURLOPT_CAINFO, $caCert);
+				}
+			}
+
+			/**
+			 * Verify the host name in the certificate and the certificate itself.
+			 *
+			 * Caveat: if your bucket contains dots in the name we have to turn off host verification due to the way the
+			 * S3 SSL certificates are set up.
+			 */
+			$isAmazonS3  = (substr($this->headers['Host'], -14) == '.amazonaws.com')
+			               || substr(
+				                  $this->headers['Host'], -16
+			                  ) == 'amazonaws.com.cn';
+			$tooManyDots = substr_count($this->headers['Host'], '.') > 4;
+
+			$verifyHost = ($isAmazonS3 && $tooManyDots) ? 0 : 2;
+
+			curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, $verifyHost);
+			curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, true);
+		}
+
+		curl_setopt($curl, CURLOPT_URL, $url);
+
+		/**
+		 * Set the optional x-amz-date header instead of the standard HTTP Date header.
+		 *
+		 * Amazon S3 proper expects to get the date from the Date header. It also allows you to instead use the optional
+		 * X-Amz-Date header as a means to resolve situations where your HTTP library does not let you control the
+		 * standard Date header. In other words, it accepts both, it encourages the standard Date header, but it will
+		 * give priority to the X-Amz-Date header to help you get out of a sticky situation.
+		 *
+		 * Unfortunately, third party services which claim to be "S3-compatible" are written by people with poor reading
+		 * skills or, more likely, under unrealistic time constraints to deliver working code. They are implementing the
+		 * date handling behaviout wrong. Instead of using the Date header unless the X-Amz-Date header is set, they
+		 * **expect** to only ever see the X-Amz-Date header. If it's missing, they do not fall back to the standard
+		 * Date header; they just spit out a message about the signature being wrong. Wasabi and ExoScale are two prime
+		 * examples of that, and only when using v2 signatures.
+		 *
+		 * To avoid this problem, we are now defaulting to always using the X-Amz-Date header everywhere. If you want to
+		 * revert to using the Date header with S3 proper please use setUseHTTPDateHeader(true) to your configuration
+		 * object. In this case DO NOT set the X-Amz-Date header yourself, or you're going to have a *really* bad time.
+		 */
+		if (!$this->configuration->getUseHTTPDateHeader())
+		{
+			$this->amzHeaders['x-amz-date'] = (new \DateTime($this->headers['Date']))->format('Ymd\THis\Z');
+
+			unset ($this->headers['Date']);
+		}
+
+		/**
+		 * Remove empty headers.
+		 *
+		 * While Amazon S3 proper and most third party implementations have no problem with that, there a few of them
+		 * (such as Synology C2) which choke on empty headers.
+		 */
+		$this->headers = array_filter($this->headers);
+
+		// Get the request signature
+		$signer = Signature::getSignatureObject($this, $this->configuration->getSignatureMethod());
+		$signer->preProcessHeaders($this->headers, $this->amzHeaders);
+
+		// Headers
+		$headers = [];
+
+		foreach ($this->amzHeaders as $header => $value)
+		{
+			if (strlen($value) > 0)
+			{
+				$headers[] = $header . ': ' . $value;
+			}
+		}
+
+		foreach ($this->headers as $header => $value)
+		{
+			if (strlen($value) > 0)
+			{
+				$headers[] = $header . ': ' . $value;
+			}
+		}
+
+		$headers[] = 'Authorization: ' . $signer->getAuthorizationHeader();
+
+		curl_setopt($curl, CURLOPT_HTTPHEADER, $headers);
+		curl_setopt($curl, CURLOPT_HEADER, false);
+		curl_setopt($curl, CURLOPT_RETURNTRANSFER, false);
+		curl_setopt($curl, CURLOPT_WRITEFUNCTION, [$this, '__responseWriteCallback']);
+		curl_setopt($curl, CURLOPT_HEADERFUNCTION, [$this, '__responseHeaderCallback']);
+		curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
+
+		// Request types
+		switch ($this->verb)
+		{
+			case 'GET':
+				break;
+
+			case 'PUT':
+			case 'POST':
+				if (!is_object($this->input) || !($this->input instanceof Input))
+				{
+					$this->input = new Input();
+				}
+
+				$size = $this->input->getSize();
+				$type = $this->input->getInputType();
+
+				if ($type == Input::INPUT_DATA)
+				{
+					curl_setopt($curl, CURLOPT_CUSTOMREQUEST, $this->verb);
+
+					$data = $this->input->getDataReference();
+
+					if (strlen($data ?? ''))
+					{
+						curl_setopt($curl, CURLOPT_POSTFIELDS, $data);
+					}
+
+					if ($size > 0)
+					{
+						curl_setopt($curl, CURLOPT_BUFFERSIZE, $size);
+					}
+				}
+				else
+				{
+					curl_setopt($curl, CURLOPT_PUT, true);
+					curl_setopt($curl, CURLOPT_INFILE, $this->input->getFp());
+
+					if ($size > 0)
+					{
+						curl_setopt($curl, CURLOPT_INFILESIZE, $size);
+					}
+				}
+
+
+				break;
+
+			case 'HEAD':
+				curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'HEAD');
+				curl_setopt($curl, CURLOPT_NOBODY, true);
+				break;
+
+			case 'DELETE':
+				curl_setopt($curl, CURLOPT_CUSTOMREQUEST, 'DELETE');
+				break;
+
+			default:
+				break;
+		}
+
+		// Execute, grab errors
+		$this->response->resetBody();
+
+		if (curl_exec($curl))
+		{
+			$this->response->code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
+		}
+		else
+		{
+			$this->response->error = new Error(
+				curl_errno($curl),
+				curl_error($curl),
+				$this->resource
+			);
+		}
+
+		@curl_close($curl);
+
+		// Set the body data
+		$this->response->finaliseBody($rawResponse);
+
+		// Clean up file resources
+		if (!is_null($this->fp) && is_resource($this->fp))
+		{
+			try
+			{
+				@fclose($this->fp);
+			}
+			catch (\Throwable $e)
+			{
+			}
+		}
+
+		return $this->response;
+	}
+
+
+	/**
+	 * cURL write callback
+	 *
+	 * @param   resource   &$curl  cURL resource
+	 * @param   string     &$data  Data
+	 *
+	 * @return  int  Length in bytes
+	 */
+	protected function __responseWriteCallback($curl, string $data): int
+	{
+		if (in_array($this->response->code, [0, 200, 206]) && !is_null($this->fp) && is_resource($this->fp))
+		{
+			return fwrite($this->fp, $data);
+		}
+
+		$this->response->addToBody($data);
+
+		return strlen($data);
+	}
+
+	/**
+	 * cURL header callback
+	 *
+	 * @param   resource   $curl  cURL resource
+	 * @param   string    &$data  Data
+	 *
+	 * @return  int  Length in bytes
+	 */
+	protected function __responseHeaderCallback($curl, string $data): int
+	{
+		if (($strlen = strlen($data)) <= 2)
+		{
+			return $strlen;
+		}
+
+		if (substr($data, 0, 4) == 'HTTP')
+		{
+			$this->response->code = (int) substr($data, 9, 3);
+
+			return $strlen;
+		}
+
+		// Ignore malformed headers without a value.
+		if (strpos($data, ':') === false)
+		{
+			return $strlen;
+		}
+
+		[$header, $value] = explode(':', trim($data), 2);
+		$header = trim($header ?? '');
+		$value  = trim($value ?? '');
+
+		switch (strtolower($header))
+		{
+			case 'last-modified':
+				$this->response->setHeader('time', strtotime($value));
+				break;
+
+			case 'content-length':
+				$this->response->setHeader('size', (int) $value);
+				break;
+
+			case 'content-type':
+				$this->response->setHeader('type', $value);
+				break;
+
+			case 'etag':
+				$this->response->setHeader('hash', trim($value, '"'));
+				break;
+
+			default:
+				$this->response->setHeader(strtolower($header), is_numeric($value) ? (int) $value : $value);
+
+				if (preg_match('/^x-amz-meta-.*$/', $header))
+				{
+					$this->setHeader($header, is_numeric($value) ? (int) $value : $value);
+				}
+				break;
+		}
+
+		return $strlen;
+	}
+
+	/**
+	 * Processes $this->parameters as a query string into $this->resource
+	 *
+	 * @return  void
+	 */
+	private function processParametersIntoResource(): void
+	{
+		if (count($this->parameters))
+		{
+			$query = substr($this->uri, -1) !== '?' ? '?' : '&';
+
+			ksort($this->parameters);
+
+			foreach ($this->parameters as $var => $value)
+			{
+				if ($value == null || $value == '')
+				{
+					$query .= $var . '&';
+				}
+				else
+				{
+					// Parameters must be URL-encoded
+					$query .= $var . '=' . rawurlencode($value) . '&';
+				}
+			}
+
+			$query     = substr($query, 0, -1);
+			$this->uri .= $query;
+
+			if (array_key_exists('acl', $this->parameters) || array_key_exists('location', $this->parameters)
+			    || array_key_exists('torrent', $this->parameters)
+			    || array_key_exists('logging', $this->parameters)
+			    || array_key_exists('uploads', $this->parameters)
+			    || array_key_exists('uploadId', $this->parameters)
+			    || array_key_exists('partNumber', $this->parameters)
+			)
+			{
+				$this->resource .= $query;
+			}
+		}
+	}
+
+	/**
+	 * Get the region-specific hostname for an operation given a configuration and a bucket name. This ensures we can
+	 * always use an HTTPS connection, even with buckets containing dots in their names, without SSL certificate host
+	 * name validation issues.
+	 *
+	 * Please note that this requires the pathStyle flag to be set in Configuration because Amazon RECOMMENDS using the
+	 * virtual-hosted style request where applicable. See http://docs.aws.amazon.com/AmazonS3/latest/API/APIRest.html
+	 * Quoting this documentation:
+	 * "Although the path-style is still supported for legacy applications, we recommend using the virtual-hosted style
+	 * where applicable."
+	 *
+	 * @param   Configuration  $configuration
+	 * @param   string         $bucket
+	 *
+	 * @return  string
+	 */
+	private function getHostName(Configuration $configuration, string $bucket): string
+	{
+		// http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
+		$endpoint = $configuration->getEndpoint();
+		$region   = $configuration->getRegion();
+
+		// If it's a bucket in China we need to use a different endpoint
+		if (($endpoint == 's3.amazonaws.com') && (substr($region, 0, 3) == 'cn-'))
+		{
+			$endpoint = 'amazonaws.com.cn';
+		}
+
+		/**
+		 * If there is no bucket we use the default endpoint, whatever it is. For Amazon S3 this format is only used
+		 * when we are making account-level, cross-region requests, e.g. list all buckets. For S3-compatible APIs it
+		 * depends on the API, but generally it's just for listing available buckets.
+		 */
+		if (empty($bucket))
+		{
+			return $endpoint;
+		}
+
+		/**
+		 * Are we using v2 signatures? In this case we use the endpoint defined by the user without translating it.
+		 */
+		if ($configuration->getSignatureMethod() != 'v4')
+		{
+			// Legacy path style: the hostname is the endpoint
+			if ($configuration->getUseLegacyPathStyle())
+			{
+				return $endpoint;
+			}
+
+			// Virtual hosting style: the hostname is the bucket, dot and endpoint.
+			return $bucket . '.' . $endpoint;
+		}
+
+		/**
+		 * Only applies to Amazon S3 proper.
+		 *
+		 * When using the Amazon S3 with the v4 signature API we have to use a different hostname per region. The
+		 * mapping can be found in https://docs.aws.amazon.com/general/latest/gr/s3.html#s3_region
+		 *
+		 * This means changing the endpoint to s3.REGION.amazonaws.com with the following exceptions:
+		 * For China: s3.REGION.amazonaws.com.cn
+		 *
+		 * v4 signing does NOT support non-Amazon endpoints.
+		 */
+		if (in_array($endpoint, ['s3.amazonaws.com', 'amazonaws.com.cn']))
+		{
+			// Most endpoints: s3-REGION.amazonaws.com
+			$regionalEndpoint = $region . '.amazonaws.com';
+
+			// Exception: China
+			if (substr($region, 0, 3) == 'cn-')
+			{
+				// Chinese endpoint, e.g.: s3.cn-north-1.amazonaws.com.cn
+				$regionalEndpoint = $regionalEndpoint . '.cn';
+			}
+
+			// If dual-stack URLs are enabled then prepend the endpoint
+			if ($configuration->getDualstackUrl())
+			{
+				$endpoint = 's3.dualstack.' . $regionalEndpoint;
+			}
+			else
+			{
+				$endpoint = 's3.' . $regionalEndpoint;
+			}
+		}
+
+		// Legacy path style access: return just the endpoint
+		if ($configuration->getUseLegacyPathStyle())
+		{
+			return $endpoint;
+		}
+
+		// Recommended virtual hosting access: bucket, dot, endpoint.
+		return $bucket . '.' . $endpoint;
+	}
+}

+ 351 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Response.php

@@ -0,0 +1,351 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3;
+
+use Akeeba\S3\Exception\PropertyNotFound;
+use Akeeba\S3\Response\Error;
+use SimpleXMLElement;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+/**
+ * Amazon S3 API response object
+ *
+ * @property   Error                        $error    Response error object
+ * @property   string|SimpleXMLElement|null $body     Body data
+ * @property   int                          $code     Response code
+ * @property   array                        $headers  Any headers we may have
+ */
+class Response
+{
+	/**
+	 * Error object
+	 *
+	 * @var  Error
+	 */
+	private $error = null;
+
+	/**
+	 * Response body
+	 *
+	 * @var  string|SimpleXMLElement|null
+	 */
+	private $body = null;
+
+	/**
+	 * Status code of the response, e.g. 200 for OK, 403 for Forbidden etc
+	 *
+	 * @var  int
+	 */
+	private $code = 0;
+
+	/**
+	 * Response headers
+	 *
+	 * @var  array
+	 */
+	private $headers = [];
+
+	/**
+	 * Response constructor.
+	 */
+	public function __construct()
+	{
+		$this->error = new Error();
+	}
+
+	/**
+	 * Is this an error response?
+	 *
+	 * @return  bool
+	 */
+	public function isError(): bool
+	{
+		return is_null($this->error) || $this->error->isError();
+	}
+
+	/**
+	 * Does this response have a body?
+	 *
+	 * @return  bool
+	 */
+	public function hasBody(): bool
+	{
+		return !empty($this->body);
+	}
+
+	/**
+	 * Get the response error object
+	 *
+	 * @return  Error
+	 */
+	public function getError(): Error
+	{
+		return $this->error;
+	}
+
+	/**
+	 * Set the response error object
+	 *
+	 * @param   Error  $error
+	 */
+	public function setError(Error $error): void
+	{
+		$this->error = $error;
+	}
+
+	/**
+	 * Get the response body
+	 *
+	 * If there is no body set up you get NULL.
+	 *
+	 * If the body is binary data (e.g. downloading a file) or other non-XML data you get a string.
+	 *
+	 * If the body was an XML string – the standard Amazon S3 REST API response type – you get a SimpleXMLElement
+	 * object.
+	 *
+	 * @return string|SimpleXMLElement|null
+	 */
+	public function getBody()
+	{
+		return $this->body;
+	}
+
+	/**
+	 * Set the response body. If it's a string we'll try to parse it as XML.
+	 *
+	 * @param   string|SimpleXMLElement|null  $body
+	 */
+	public function setBody($body, bool $rawResponse = false): void
+	{
+		$this->body = null;
+
+		if (empty($body))
+		{
+			return;
+		}
+
+		$this->body = $body;
+
+		$this->finaliseBody($rawResponse);
+	}
+
+	public function resetBody(): void
+	{
+		$this->body = null;
+	}
+
+	public function addToBody(string $data): void
+	{
+		if (empty($this->body))
+		{
+			$this->body = '';
+		}
+
+		$this->body .= $data;
+	}
+
+	public function finaliseBody(bool $rawResponse = false): void
+	{
+		if (!$this->hasBody())
+		{
+			return;
+		}
+
+		if (!isset($this->headers['type']))
+		{
+			$this->headers['type'] = 'text/plain';
+		}
+
+		if (
+			!$rawResponse
+			&& is_string($this->body)
+			&&
+			(
+				($this->headers['type'] == 'application/xml')
+				|| (substr($this->body, 0, 5) == '<?xml')
+			)
+		)
+		{
+			$this->body = simplexml_load_string($this->body);
+		}
+
+		if (is_object($this->body) && ($this->body instanceof SimpleXMLElement))
+		{
+			$this->parseBody();
+		}
+	}
+
+	/**
+	 * Returns the status code of the response
+	 *
+	 * @return  int
+	 */
+	public function getCode(): int
+	{
+		return $this->code;
+	}
+
+	/**
+	 * Sets the status code of the response
+	 *
+	 * @param   int  $code
+	 */
+	public function setCode(int $code): void
+	{
+		$this->code = $code;
+	}
+
+	/**
+	 * Get the response headers
+	 *
+	 * @return  array
+	 */
+	public function getHeaders(): array
+	{
+		return $this->headers;
+	}
+
+	/**
+	 * Set the response headers
+	 *
+	 * @param   array  $headers
+	 */
+	public function setHeaders(array $headers): void
+	{
+		$this->headers = $headers;
+	}
+
+	/**
+	 * Set a single header
+	 *
+	 * @param   string  $name   The header name
+	 * @param   string  $value  The header value
+	 *
+	 * @return  void
+	 */
+	public function setHeader(string $name, string $value): void
+	{
+		$this->headers[$name] = $value;
+	}
+
+	/**
+	 * Does a header by this name exist?
+	 *
+	 * @param   string  $name  The header to look for
+	 *
+	 * @return  bool  True if it exists
+	 */
+	public function hasHeader(string $name): bool
+	{
+		return array_key_exists($name, $this->headers);
+	}
+
+	/**
+	 * Unset a response header
+	 *
+	 * @param   string  $name  The header to unset
+	 *
+	 * @return  void
+	 */
+	public function unsetHeader(string $name): void
+	{
+		if ($this->hasHeader($name))
+		{
+			unset ($this->headers[$name]);
+		}
+	}
+
+	/**
+	 * Magic getter for the protected properties
+	 *
+	 * @param   string  $name
+	 *
+	 * @return  mixed
+	 */
+	public function __get(string $name)
+	{
+		switch ($name)
+		{
+			case 'error':
+				return $this->getError();
+				break;
+
+			case 'body':
+				return $this->getBody();
+				break;
+
+			case 'code':
+				return $this->getCode();
+				break;
+
+			case 'headers':
+				return $this->getHeaders();
+				break;
+		}
+
+		throw new PropertyNotFound("Property $name not found in " . get_class($this));
+	}
+
+	/**
+	 * Magic setter for the protected properties
+	 *
+	 * @param   string  $name   The name of the property
+	 * @param   mixed   $value  The value of the property
+	 *
+	 * @return  void
+	 */
+	public function __set(string $name, $value): void
+	{
+		switch ($name)
+		{
+			case 'error':
+				$this->setError($value);
+				break;
+
+			case 'body':
+				$this->setBody($value);
+				break;
+
+			case 'code':
+				$this->setCode($value);
+				break;
+
+			case 'headers':
+				$this->setHeaders($value);
+				break;
+
+			default:
+				throw new PropertyNotFound("Property $name not found in " . get_class($this));
+		}
+	}
+
+	/**
+	 * Scans the SimpleXMLElement body for errors and propagates them to the Error object
+	 */
+	protected function parseBody(): void
+	{
+		if (!in_array($this->code, [200, 204]) &&
+			isset($this->body->Code, $this->body->Message)
+		)
+		{
+			$this->error = new Error(
+				500,
+				(string) $this->body->Code . ':' . (string) $this->body->Message
+			);
+
+			if (isset($this->body->Resource))
+			{
+				$this->error->setResource((string) $this->body->Resource);
+			}
+		}
+	}
+}

+ 139 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Response/Error.php

@@ -0,0 +1,139 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Response;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+/**
+ * S3 response error object
+ */
+class Error
+{
+	/**
+	 * Error code
+	 *
+	 * @var  int
+	 */
+	private $code = 0;
+
+	/**
+	 * Error message
+	 *
+	 * @var  string
+	 */
+	private $message = '';
+
+	/**
+	 * URI to the resource that throws the error
+	 *
+	 * @var  string
+	 */
+	private $resource = '';
+
+	/**
+	 * Create a new error object
+	 *
+	 * @param   int     $code      The error code
+	 * @param   string  $message   The error message
+	 * @param   string  $resource  The URI to the resource throwing the error
+	 *
+	 * @return  void
+	 */
+	function __construct($code = 0, $message = '', $resource = '')
+	{
+		$this->setCode($code);
+		$this->setMessage($message);
+		$this->setResource($resource);
+	}
+
+	/**
+	 * Get the error code
+	 *
+	 * @return  int
+	 */
+	public function getCode(): int
+	{
+		return $this->code;
+	}
+
+	/**
+	 * Set the error code
+	 *
+	 * @param   int  $code  Set to zeroo or a negative value to clear errors
+	 *
+	 * @return  void
+	 */
+	public function setCode(int $code): void
+	{
+		if ($code <= 0)
+		{
+			$code = 0;
+			$this->setMessage('');
+			$this->setResource('');
+		}
+
+		$this->code = $code;
+	}
+
+	/**
+	 * Get the error message
+	 *
+	 * @return  string
+	 */
+	public function getMessage(): string
+	{
+		return $this->message;
+	}
+
+	/**
+	 * Set the error message
+	 *
+	 * @param   string  $message  The error message to set
+	 *
+	 * @return  void
+	 */
+	public function setMessage(string $message): void
+	{
+		$this->message = $message;
+	}
+
+	/**
+	 * Get the URI of the resource throwing the error
+	 *
+	 * @return  string
+	 */
+	public function getResource(): string
+	{
+		return $this->resource;
+	}
+
+	/**
+	 * Set the URI of the resource throwing the error
+	 *
+	 * @param   string  $resource
+	 *
+	 * @return  void
+	 */
+	public function setResource(string $resource): void
+	{
+		$this->resource = $resource;
+	}
+
+	/**
+	 * Do we actually have an error?
+	 *
+	 * @return  bool
+	 */
+	public function isError(): bool
+	{
+		return ($this->code > 0) || !empty($this->message);
+	}
+}

+ 80 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Signature.php

@@ -0,0 +1,80 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+/**
+ * Base class for request signing objects.
+ */
+abstract class Signature
+{
+	/**
+	 * The request we will be signing
+	 *
+	 * @var  Request
+	 */
+	protected $request = null;
+
+	/**
+	 * Signature constructor.
+	 *
+	 * @param   Request  $request  The request we will be signing
+	 */
+	public function __construct(Request $request)
+	{
+		$this->request = $request;
+	}
+
+	/**
+	 * Get a signature object for the request
+	 *
+	 * @param   Request  $request  The request which needs signing
+	 * @param   string   $method   The signature method, "v2" or "v4"
+	 *
+	 * @return  Signature
+	 */
+	public static function getSignatureObject(Request $request, string $method = 'v2'): self
+	{
+		$className = __NAMESPACE__ . '\\Signature\\' . ucfirst($method);
+
+		return new $className($request);
+	}
+
+	/**
+	 * Returns the authorization header for the request
+	 *
+	 * @return  string
+	 */
+	abstract public function getAuthorizationHeader(): string;
+
+	/**
+	 * Pre-process the request headers before we convert them to cURL-compatible format. Used by signature engines to
+	 * add custom headers, e.g. x-amz-content-sha256
+	 *
+	 * @param   array  $headers     The associative array of headers to process
+	 * @param   array  $amzHeaders  The associative array of amz-* headers to process
+	 *
+	 * @return  void
+	 */
+	abstract public function preProcessHeaders(array &$headers, array &$amzHeaders): void;
+
+	/**
+	 * Get a pre-signed URL for the request. Typically used to pre-sign GET requests to objects, i.e. give shareable
+	 * pre-authorized URLs for downloading files from S3.
+	 *
+	 * @param   integer|null  $lifetime  Lifetime in seconds. NULL for default lifetime.
+	 * @param   bool          $https     Use HTTPS ($hostBucket should be false for SSL verification)?
+	 *
+	 * @return  string  The authenticated URL, complete with signature
+	 */
+	abstract public function getAuthenticatedURL(?int $lifetime = null, bool $https = false): string;
+}

+ 218 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Signature/V2.php

@@ -0,0 +1,218 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Signature;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+use Akeeba\S3\Signature;
+
+/**
+ * Implements the Amazon AWS v2 signatures
+ *
+ * @see http://docs.aws.amazon.com/AmazonS3/latest/dev/RESTAuthentication.html
+ */
+class V2 extends Signature
+{
+	/**
+	 * Pre-process the request headers before we convert them to cURL-compatible format. Used by signature engines to
+	 * add custom headers, e.g. x-amz-content-sha256
+	 *
+	 * @param   array  $headers     The associative array of headers to process
+	 * @param   array  $amzHeaders  The associative array of amz-* headers to process
+	 *
+	 * @return  void
+	 */
+	public function preProcessHeaders(array &$headers, array &$amzHeaders): void
+	{
+		// No pre-processing required for V2 signatures
+	}
+
+	/**
+	 * Get a pre-signed URL for the request. Typically used to pre-sign GET requests to objects, i.e. give shareable
+	 * pre-authorized URLs for downloading files from S3.
+	 *
+	 * @param   integer|null  $lifetime  Lifetime in seconds. NULL for default lifetime.
+	 * @param   bool          $https     Use HTTPS ($hostBucket should be false for SSL verification)?
+	 *
+	 * @return  string  The presigned URL
+	 */
+	public function getAuthenticatedURL(?int $lifetime = null, bool $https = false): string
+	{
+		// Set the Expires header
+		if (is_null($lifetime))
+		{
+			$lifetime = 10;
+		}
+
+		$expires = time() + $lifetime;
+		$this->request->setHeader('Expires', $expires);
+
+		$bucket    = $this->request->getBucket();
+		$uri       = $this->request->getResource();
+		$headers   = $this->request->getHeaders();
+		$accessKey = $this->request->getConfiguration()->getAccess();
+		$protocol  = $https ? 'https' : 'http';
+		$signature = $this->getAuthorizationHeader();
+
+		$search = '/' . $bucket;
+
+		// This does not look right... The bucket name must be included in the URL.
+//		 if (strpos($uri, $search) === 0)
+//		 {
+//		 	$uri = substr($uri, strlen($search));
+//		 }
+
+		$queryParameters = array_merge($this->request->getParameters(), [
+			'AWSAccessKeyId' => $accessKey,
+			'Expires'        => sprintf('%u', $expires),
+			'Signature'      => $signature,
+		]);
+
+		$query = http_build_query($queryParameters);
+
+		// fix authenticated url for Google Cloud Storage - https://cloud.google.com/storage/docs/access-control/create-signed-urls-program
+		if ($this->request->getConfiguration()->getEndpoint() === "storage.googleapis.com")
+		{
+			// replace host with endpoint
+			$headers['Host'] = 'storage.googleapis.com';
+			// replace "AWSAccessKeyId" with "GoogleAccessId"
+			$query = str_replace('AWSAccessKeyId', 'GoogleAccessId', $query);
+			// add bucket to url
+			$uri = '/' . $bucket . $uri;
+		}
+
+		$url = $protocol . '://' . $headers['Host'] . $uri;
+		$url .= (strpos($uri, '?') !== false) ? '&' : '?';
+		$url .= $query;
+
+		return $url;
+	}
+
+	/**
+	 * Returns the authorization header for the request
+	 *
+	 * @return  string
+	 */
+	public function getAuthorizationHeader(): string
+	{
+		$verb           = strtoupper($this->request->getVerb());
+		$resourcePath   = $this->request->getResource();
+		$headers        = $this->request->getHeaders();
+		$amzHeaders     = $this->request->getAmzHeaders();
+		$parameters     = $this->request->getParameters();
+		$bucket         = $this->request->getBucket();
+		$isPresignedURL = false;
+
+		$amz       = [];
+		$amzString = '';
+
+		// Collect AMZ headers for signature
+		foreach ($amzHeaders as $header => $value)
+		{
+			if (strlen($value) > 0)
+			{
+				$amz[] = strtolower($header) . ':' . $value;
+			}
+		}
+
+		// AMZ headers must be sorted and sent as separate lines
+		if (count($amz) > 0)
+		{
+			sort($amz);
+			$amzString = "\n" . implode("\n", $amz);
+		}
+
+		// If the Expires query string parameter is set up we're pre-signing a download URL. The string to sign is a bit
+		// different in this case; it does not include the Date, it includes the Expires.
+		// See http://docs.aws.amazon.com/AmazonS3/latest/dev/RESTAuthentication.html#RESTAuthenticationQueryStringAuth
+		if (isset($headers['Expires']))
+		{
+			if (isset($headers['Date']))
+			{
+				$headers['Date'] = $headers['Expires'];
+			}
+			else
+			{
+				$amzHeaders['x-amz-date'] = $headers['Expires'];
+			}
+
+			unset ($headers['Expires']);
+
+			$isPresignedURL = true;
+		}
+
+		/**
+		 * The resource path in S3 V2 signatures must ALWAYS contain the bucket name if a bucket is defined, even if we
+		 * are not using path-style access to the resource
+		 */
+		if (!empty($bucket) && !$this->request->getConfiguration()->getUseLegacyPathStyle())
+		{
+			$resourcePath = '/' . $bucket . $resourcePath;
+		}
+
+		$stringToSign = $verb . "\n" .
+			($headers['Content-MD5'] ?? '') . "\n" .
+			($headers['Content-Type'] ?? '') . "\n" .
+			($headers['Date'] ?? '') .
+			$amzString . "\n" .
+			$resourcePath;
+
+		// CloudFront only requires a date to be signed
+		if ($headers['Host'] == 'cloudfront.amazonaws.com')
+		{
+			$stringToSign = $headers['Date'] ?? $amzHeaders['x-amz-date'] ?? '';
+		}
+
+		$amazonV2Hash = $this->amazonV2Hash($stringToSign);
+
+		// For presigned URLs we only return the Base64-encoded signature without the AWS format specifier and the
+		// public access key.
+		if ($isPresignedURL)
+		{
+			return $amazonV2Hash;
+		}
+
+		return 'AWS ' .
+			$this->request->getConfiguration()->getAccess() . ':' .
+			$amazonV2Hash;
+	}
+
+	/**
+	 * Creates a HMAC-SHA1 hash. Uses the hash extension if present, otherwise falls back to slower, manual calculation.
+	 *
+	 * @param   string  $stringToSign  String to sign
+	 *
+	 * @return  string
+	 */
+	private function amazonV2Hash(string $stringToSign): string
+	{
+		$secret = $this->request->getConfiguration()->getSecret();
+
+		if (extension_loaded('hash'))
+		{
+			$raw = hash_hmac('sha1', $stringToSign, $secret, true);
+
+			return base64_encode($raw);
+		}
+
+		$raw = pack('H*', sha1(
+				(str_pad($secret, 64, chr(0x00)) ^ (str_repeat(chr(0x5c), 64))) .
+				pack('H*', sha1(
+						(str_pad($secret, 64, chr(0x00)) ^ (str_repeat(chr(0x36), 64))) . $stringToSign
+					)
+				)
+			)
+		);
+
+		return base64_encode($raw);
+	}
+
+}

+ 534 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/Signature/V4.php

@@ -0,0 +1,534 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3\Signature;
+
+// Protection against direct access
+defined('AKEEBAENGINE') || die();
+
+use Akeeba\S3\Signature;
+use DateTime;
+
+/**
+ * Implements the Amazon AWS v4 signatures
+ *
+ * @see http://docs.aws.amazon.com/general/latest/gr/signature-version-4.html
+ */
+class V4 extends Signature
+{
+	/**
+	 * Pre-process the request headers before we convert them to cURL-compatible format. Used by signature engines to
+	 * add custom headers, e.g. x-amz-content-sha256
+	 *
+	 * @param   array  $headers     The associative array of headers to process
+	 * @param   array  $amzHeaders  The associative array of amz-* headers to process
+	 *
+	 * @return  void
+	 */
+	public function preProcessHeaders(array &$headers, array &$amzHeaders): void
+	{
+		// Do we already have an SHA-256 payload hash?
+		if (isset($amzHeaders['x-amz-content-sha256']))
+		{
+			return;
+		}
+
+		// Set the payload hash header
+		$input = $this->request->getInput();
+
+		if (is_object($input))
+		{
+			$requestPayloadHash = $input->getSha256();
+		}
+		else
+		{
+			$requestPayloadHash = hash('sha256', '', false);
+		}
+
+		$amzHeaders['x-amz-content-sha256'] = $requestPayloadHash;
+	}
+
+	/**
+	 * Get a pre-signed URL for the request. Typically used to pre-sign GET requests to objects, i.e. give shareable
+	 * pre-authorized URLs for downloading files from S3.
+	 *
+	 * @param   integer|null  $lifetime  Lifetime in seconds. NULL for default lifetime.
+	 * @param   bool          $https     Use HTTPS ($hostBucket should be false for SSL verification)?
+	 *
+	 * @return  string  The presigned URL
+	 */
+	public function getAuthenticatedURL(?int $lifetime = null, bool $https = false): string
+	{
+		// Set the Expires header
+		if (is_null($lifetime))
+		{
+			$lifetime = 10;
+		}
+
+		/**
+		 * Authenticated URLs must always go through the generic regional endpoint, not the virtual hosting-style domain
+		 * name. This means that if you have a bucket "example" in the EU West 1 (Ireland) region we have to go through
+		 * http://s3-eu-west-1.amazonaws.com/example instead of http://example.amazonaws.com/ for all authenticated URLs
+		 */
+		$region   = $this->request->getConfiguration()->getRegion();
+		$bucket   = $this->request->getBucket();
+		$hostname = $this->getPresignedHostnameForRegion($region);
+
+		if (!$this->request->getConfiguration()->getPreSignedBucketInURL() && $this->isValidBucketName($bucket))
+		{
+			$hostname = $bucket . '.' . $hostname;
+		}
+
+		$this->request->setHeader('Host', $hostname);
+
+		// Set the expiration time in seconds
+		$this->request->setHeader('Expires', (int) $lifetime);
+
+		// Get the query parameters, including the calculated signature
+		$uri              = $this->request->getResource();
+		$headers          = $this->request->getHeaders();
+		$protocol         = $https ? 'https' : 'http';
+		$serialisedParams = $this->getAuthorizationHeader();
+
+		// The query parameters are returned serialized; unserialize them, then build and return the URL.
+		$queryParameters = unserialize($serialisedParams);
+
+		// This should be toggleable
+		if (
+			!$this->request->getConfiguration()->getPreSignedBucketInURL()
+			&& $this->isValidBucketName($bucket)
+			&& strpos($uri, '/' . $bucket) === 0)
+		{
+			$uri = substr($uri, strlen($bucket) + 1);
+		}
+
+		$query = http_build_query($queryParameters);
+
+		$url = $protocol . '://' . $headers['Host'] . $uri;
+		$url .= (strpos($uri, '?') !== false) ? '&' : '?';
+		$url .= $query;
+
+		return $url;
+	}
+
+	/**
+	 * Returns the authorization header for the request
+	 *
+	 * @return  string
+	 */
+	public function getAuthorizationHeader(): string
+	{
+		$verb           = strtoupper($this->request->getVerb());
+		$resourcePath   = $this->request->getResource();
+		$headers        = $this->request->getHeaders();
+		$amzHeaders     = $this->request->getAmzHeaders();
+		$parameters     = $this->request->getParameters();
+		$bucket         = $this->request->getBucket();
+		$isPresignedURL = false;
+
+		// See the Connector class for the explanation behind this ugly workaround
+		$amazonIsBraindead = isset($headers['workaround-braindead-error-from-amazon']);
+
+		if ($amazonIsBraindead)
+		{
+			unset ($headers['workaround-braindead-error-from-amazon']);
+		}
+
+		// Get the credentials scope
+		$signatureDate = new DateTime($headers['Date'] ?? $amzHeaders['x-amz-date']);
+
+		$credentialScope = $signatureDate->format('Ymd') . '/' .
+		                   $this->request->getConfiguration()->getRegion() . '/' .
+		                   's3/aws4_request';
+
+		/**
+		 * If the Expires header is set up we're pre-signing a download URL. The string to sign is a bit
+		 * different in this case and we have to pass certain headers as query string parameters.
+		 *
+		 * @see http://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
+		 */
+		if (isset($headers['Expires']))
+		{
+			$gmtDate = clone $signatureDate;
+			$gmtDate->setTimezone(new \DateTimeZone('GMT'));
+
+			$parameters['X-Amz-Algorithm']  = "AWS4-HMAC-SHA256";
+			$parameters['X-Amz-Credential'] = $this->request->getConfiguration()->getAccess() . '/' . $credentialScope;
+			$parameters['X-Amz-Date']       = $gmtDate->format('Ymd\THis\Z');
+			$parameters['X-Amz-Expires']    = sprintf('%u', $headers['Expires']);
+			$token                          = $this->request->getConfiguration()->getToken();
+
+			if (!empty($token))
+			{
+				$parameters['x-amz-security-token'] = $token;
+			}
+
+			unset($headers['Expires']);
+			unset($headers['Date']);
+			unset($headers['Content-MD5']);
+			unset($headers['Content-Type']);
+
+			$isPresignedURL = true;
+		}
+
+		// ========== Step 1: Create a canonical request ==========
+		// See http://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html
+
+		$canonicalHeaders   = "";
+		$signedHeadersArray = [];
+
+		// Calculate the canonical headers and the signed headers
+		if ($isPresignedURL)
+		{
+			// Presigned URLs use UNSIGNED-PAYLOAD instead
+			unset($amzHeaders['x-amz-content-sha256']);
+		}
+
+		$allHeaders = array_merge($headers, $amzHeaders);
+		ksort($allHeaders);
+
+		foreach ($allHeaders as $k => $v)
+		{
+			$lowercaseHeaderName = strtolower($k);
+
+			if ($amazonIsBraindead && ($lowercaseHeaderName == 'content-length'))
+			{
+				/**
+				 * I know it looks crazy. It is. Somehow Amazon requires me to do this and only on _some_ servers, mind
+				 * you. This is something undocumented and which is not covered by their official SDK. I had to write
+				 * my own library because of that and the official SDK's inability to upload large files without using
+				 * at least as much memory as the file itself (which doesn't fly well for files around 2Gb, let me tell
+				 * you that!).
+				 */
+				$v = "$v,$v";
+			}
+
+			$canonicalHeaders     .= $lowercaseHeaderName . ':' . trim($v) . "\n";
+			$signedHeadersArray[] = $lowercaseHeaderName;
+		}
+
+		$signedHeaders = implode(';', $signedHeadersArray);
+
+		if ($isPresignedURL)
+		{
+			$parameters['X-Amz-SignedHeaders'] = $signedHeaders;
+		}
+
+		// The canonical URI is the resource path
+		$canonicalURI     = $resourcePath;
+		$bucketResource   = '/' . $bucket . '/';
+		$regionalHostname = ($headers['Host'] != 's3.amazonaws.com')
+		                    && ($headers['Host'] != $bucket . '.s3.amazonaws.com');
+
+		/**
+		 * Yet another special case for third party, S3-compatible services, when using pre-signed URLs.
+		 *
+		 * Given a bucket `example` and filepath `foo/bar.txt` the canonical URI to sign is supposed to be
+		 * /example/foo/bar.txt regardless of whether we are using path style or subdomain hosting style access to the
+		 * bucket.
+		 *
+		 * When calculating a pre-signed URL, the URL we will be accessing will be something to the tune of
+		 * example.endpoint.com/foo/bar.txt. Amazon S3 proper allows us to use EITHER the nominal canonical URI
+		 * /foo/bar.txt OR the /example/foo/bar.txt canonical URI for consistency. Some third party providers, like
+		 * Wasabi, will choke on the former and complain about the signature being invalid.
+		 *
+		 * To address this issue we check if all the following conditions are met:
+		 * - We are calculating a signature for a pre-signed URL.
+		 * - The service is NOT Amazon S3 proper.
+		 * - The domain name starts with the bucket name.
+		 * In this case, and this case only, we set $regionalHostname to false. This triggers an if-block further down
+		 * which strips the `/bucketName/` prefix from the canonical URI, converting it to `/`. Therefore, the canonical
+		 * URI in the signature becomes the nominal URI we will be accessing in the bucket, solving the problem with
+		 * those third party services.
+		 */
+		// Figuring out whether it's a regional hostname DOES NOT work above if it's not AWS S3 proper. Let's fix that.
+		if ($isPresignedURL && strpos($headers['Host'], 'amazonaws.com') === false && !strpos($headers['Host'], $bucket . '.'))
+		{
+			$regionalHostname = false;
+		}
+
+		// Special case: if the canonical URI ends in /?location the bucket name DOES count as part of the canonical URL
+		// even though the Host is s3.amazonaws.com (in which case it normally shouldn't count). Yeah, I know, it makes
+		// no sense!!!
+		if (!$regionalHostname && ($headers['Host'] == 's3.amazonaws.com')
+		    && (substr($canonicalURI, -10) == '/?location'))
+		{
+			$regionalHostname = true;
+		}
+
+		if (!$regionalHostname && (strpos($canonicalURI, $bucketResource) === 0 || strpos($canonicalURI, substr($bucketResource, 0, -1)) === 0))
+		{
+			if ($canonicalURI === substr($bucketResource, 0, -1))
+			{
+				$canonicalURI = '/';
+			}
+			else
+			{
+				$canonicalURI = substr($canonicalURI, strlen($bucketResource) - 1);
+			}
+		}
+
+		// If the resource path has a query yank it and parse it into the parameters array
+		$questionMarkPos = strpos($canonicalURI, '?');
+
+		if ($questionMarkPos !== false)
+		{
+			$canonicalURI = substr($canonicalURI, 0, $questionMarkPos);
+			$queryString  = @substr($canonicalURI, $questionMarkPos + 1);
+			@parse_str($queryString, $extraQuery);
+
+			if (count($extraQuery))
+			{
+				$parameters = array_merge($parameters, $extraQuery);
+			}
+		}
+
+		// The canonical query string is the string representation of $parameters, alpha sorted by key
+		ksort($parameters);
+
+		// We build the query the hard way because http_build_query in PHP 5.3 does NOT have the fourth parameter
+		// (encoding type), defaulting to RFC 1738 encoding whereas S3 expects RFC 3986 encoding
+		$canonicalQueryString = '';
+
+		if (!empty($parameters))
+		{
+			$temp = [];
+
+			foreach ($parameters as $k => $v)
+			{
+				$temp[] = $this->urlencode($k) . '=' . $this->urlencode($v);
+			}
+
+			$canonicalQueryString = implode('&', $temp);
+		}
+
+		// Get the payload hash
+		$requestPayloadHash = 'UNSIGNED-PAYLOAD';
+
+		if (isset($amzHeaders['x-amz-content-sha256']))
+		{
+			$requestPayloadHash = $amzHeaders['x-amz-content-sha256'];
+		}
+
+		// Calculate the canonical request
+		$canonicalRequest = $verb . "\n" .
+		                    $canonicalURI . "\n" .
+		                    $canonicalQueryString . "\n" .
+		                    $canonicalHeaders . "\n" .
+		                    $signedHeaders . "\n" .
+		                    $requestPayloadHash;
+
+		$hashedCanonicalRequest = hash('sha256', $canonicalRequest);
+
+		// ========== Step 2: Create a string to sign ==========
+		// See http://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html
+
+		if (!isset($headers['Date']))
+		{
+			$headers['Date'] = '';
+		}
+
+		/**
+		 * The Date in the String-to-Sign is a messy situation.
+		 *
+		 * Amazon's documentation says it must be in ISO 8601 format: `Ymd\THis\Z`. Unfortunately, Amazon's
+		 * documentation is actually wrong :troll_face: The actual Amazon S3 service expects the date to be formatted as
+		 * per RFC1123.
+		 *
+		 * Most third party implementations have caught up to the fact that Amazon has documented the v4 signatures
+		 * wrongly (naughty AWS!) and accept either format.
+		 *
+		 * Some other third party implementations, which never bothered to validate their implementations against Amazon
+		 * S3 proper, only expect what Amazon has documented as "ISO 8601". Therefore, we detect third party services
+		 * and switch to the as-documented date format.
+		 *
+		 * Some other third party services, like Wasabi, are broken in yet a different way. They will only use the date
+		 * from the x-amz-date header, WITHOUT falling back to the Date header if the former is not present. This is
+		 * the opposite of Amazon S3 proper which does expect the Date header. That's why the Request class sets both
+		 * headers if the request is made to a service _other_ than Amazon S3 proper.
+		 */
+		$dateToSignFor = strpos($headers['Host'], '.amazonaws.com') !== false
+			? (($headers['Date'] ?? null) ?: ($amzHeaders['x-amz-date'] ?? null) ?: $signatureDate->format('Ymd\THis\Z'))
+			: $signatureDate->format('Ymd\THis\Z');
+
+		$stringToSign = "AWS4-HMAC-SHA256\n" .
+		                $dateToSignFor . "\n" .
+		                $credentialScope . "\n" .
+		                $hashedCanonicalRequest;
+
+		if ($isPresignedURL)
+		{
+			$stringToSign = "AWS4-HMAC-SHA256\n" .
+			                $parameters['X-Amz-Date'] . "\n" .
+			                $credentialScope . "\n" .
+			                $hashedCanonicalRequest;
+		}
+
+		// ========== Step 3: Calculate the signature ==========
+		// See http://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html
+		$kSigning = $this->getSigningKey($signatureDate);
+
+		$signature = hash_hmac('sha256', $stringToSign, $kSigning, false);
+
+		// ========== Step 4: Add the signing information to the Request ==========
+		// See http://docs.aws.amazon.com/general/latest/gr/sigv4-add-signature-to-request.html
+
+		$authorization = 'AWS4-HMAC-SHA256 Credential=' .
+		                 $this->request->getConfiguration()->getAccess() . '/' . $credentialScope . ', ' .
+		                 'SignedHeaders=' . $signedHeaders . ', ' .
+		                 'Signature=' . $signature;
+
+		// For presigned URLs we only return the Base64-encoded signature without the AWS format specifier and the
+		// public access key.
+		if ($isPresignedURL)
+		{
+			$parameters['X-Amz-Signature'] = $signature;
+
+			return serialize($parameters);
+		}
+
+		return $authorization;
+	}
+
+	/**
+	 * Calculate the AWS4 signing key
+	 *
+	 * @param   DateTime  $signatureDate  The date the signing key is good for
+	 *
+	 * @return  string
+	 */
+	private function getSigningKey(DateTime $signatureDate): string
+	{
+		$kSecret  = $this->request->getConfiguration()->getSecret();
+		$kDate    = hash_hmac('sha256', $signatureDate->format('Ymd'), 'AWS4' . $kSecret, true);
+		$kRegion  = hash_hmac('sha256', $this->request->getConfiguration()->getRegion(), $kDate, true);
+		$kService = hash_hmac('sha256', 's3', $kRegion, true);
+		$kSigning = hash_hmac('sha256', 'aws4_request', $kService, true);
+
+		return $kSigning;
+	}
+
+	private function urlencode(?string $toEncode): string
+	{
+		return empty($toEncode) ? '' : rawurlencode($toEncode);
+	}
+
+	/**
+	 * Get the correct hostname for the given AWS region
+	 *
+	 * @param   string  $region
+	 *
+	 * @return  string
+	 */
+	private function getPresignedHostnameForRegion(string $region): string
+	{
+		$config   = $this->request->getConfiguration();
+		$endpoint = $config->getEndpoint();
+
+		if (empty($endpoint))
+		{
+			$endpoint = 's3.' . $region . '.amazonaws.com';
+		}
+
+		// As of October 2023, AWS does not consider DualStack signed URLs as valid. Whatever.
+		$dualstackEnabled = false && $this->request->getConfiguration()->getDualstackUrl();
+
+		// If dual-stack URLs are enabled then prepend the endpoint
+		if ($dualstackEnabled)
+		{
+			$endpoint = 's3.dualstack.' . $region . '.amazonaws.com';
+		}
+
+		if ($region == 'cn-north-1')
+		{
+			return $endpoint . '.cn';
+		}
+
+		return $endpoint;
+	}
+
+	/**
+	 * Is this a valid bucket name?
+	 *
+	 * @param   string  $bucketName   The bucket name to check
+	 * @param   bool    $asSubdomain  Should I put additional restrictions for use as a subdomain?
+	 *
+	 * @return  bool
+	 * @since   2.3.1
+	 *
+	 * @see     https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucketnamingrules.html
+	 */
+	private function isValidBucketName(string $bucketName, bool $asSubdomain = true): bool
+	{
+		/**
+		 * If there are dots in the bucket name I can't use it as a subdomain.
+		 *
+		 * "If you include dots in a bucket's name, you can't use virtual-host-style addressing over HTTPS, unless you
+		 * perform your own certificate validation. This is because the security certificates used for virtual hosting
+		 * of buckets don't work for buckets with dots in their names."
+		 */
+		if ($asSubdomain && strpos($bucketName, '.') !== false)
+		{
+			return false;
+		}
+
+		/**
+		 * - Bucket names must be between 3 (min) and 63 (max) characters long.
+		 * - Bucket names can consist only of lowercase letters, numbers, dots (.), and hyphens (-).
+		 */
+		if (!preg_match('/^[a-z0-9\-.]{3,63}$/', $bucketName))
+		{
+			return false;
+		}
+
+		// Bucket names must begin and end with a letter or number.
+		if (!preg_match('/^[a-z0-9].*[a-z0-9]$/', $bucketName))
+		{
+			return false;
+		}
+
+		// Bucket names must not contain two adjacent periods.
+		if (preg_match('/\.\./', $bucketName))
+		{
+			return false;
+		}
+
+		// Bucket names must not be formatted as an IP address (for example, 192.168.5.4).
+		if (preg_match('/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/', $bucketName))
+		{
+			return false;
+		}
+
+		// Bucket names must not start with the prefix xn--.
+		if (strpos($bucketName, 'xn--') === 0)
+		{
+			return false;
+		}
+
+		// Bucket names must not start with the prefix sthree- and the prefix sthree-configurator.
+		if (strpos($bucketName, 'sthree-') === 0)
+		{
+			return false;
+		}
+
+		// Bucket names must not end with the suffix -s3alias.
+		if (substr($bucketName, -8) === '-s3alias')
+		{
+			return false;
+		}
+
+		// Bucket names must not end with the suffix --ol-s3.
+		if (substr($bucketName, -7) === '--ol-s3')
+		{
+			return false;
+		}
+
+		return true;
+	}
+}

+ 98 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/StorageClass.php

@@ -0,0 +1,98 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+namespace Akeeba\S3;
+
+/**
+ * Amazon S3 Storage Classes
+ *
+ * When you want to override the default storage class of the bucket pass
+ * array('X-Amz-Storage-Class' => StorageClass::STANDARD)
+ * in the $headers array of Connector::putObject().
+ *
+ * Alternatively, run the $headers array through setStorageClass(), e.g.
+ * $headers = array(); // You can put your stuff here
+ * StorageClass::setStorageClass($headers, StorageClass::STANDARD);
+ * $connector->putObject($myInput, 'bucketname', 'path/to/object.dat', Acl::PRIVATE, $headers)
+ *
+ * @see https://aws.amazon.com/s3/storage-classes/
+ */
+class StorageClass
+{
+	/**
+	 * Amazon S3 Standard (S3 Standard)
+	 */
+	public const STANDARD = 'STANDARD';
+
+	/**
+	 * Reduced redundancy storage
+	 *
+	 * Not recommended anymore. Use INTELLIGENT_TIERING instead.
+	 */
+	public const REDUCED_REDUNDANCY = 'REDUCED_REDUNDANCY';
+
+	/**
+	 * Amazon S3 Intelligent-Tiering (S3 Intelligent-Tiering)
+	 */
+	public const INTELLIGENT_TIERING = 'INTELLIGENT_TIERING';
+
+	/**
+	 * Amazon S3 Standard-Infrequent Access (S3 Standard-IA)
+	 */
+	public const STANDARD_IA = 'STANDARD_IA';
+
+	/**
+	 * Amazon S3 One Zone-Infrequent Access (S3 One Zone-IA)
+	 */
+	public const ONEZONE_IA = 'ONEZONE_IA';
+
+	/**
+	 * Amazon S3 Glacier (S3 Glacier)
+	 */
+	public const GLACIER = 'GLACIER';
+
+	/**
+	 * Amazon S3 Glacier Deep Archive (S3 Glacier Deep Archive)
+	 */
+	public const DEEP_ARCHIVE = 'DEEP_ARCHIVE';
+
+	/**
+	 * Manipulate the $headers array, setting the X-Amz-Storage-Class header for the requested storage class.
+	 *
+	 * This method will automatically remove any previously set X-Amz-Storage-Class header, case-insensitive. The reason
+	 * for that is that Amazon headers **are** case-insensitive and you could easily end up having two separate headers
+	 * with competing storage classes. This would mess up the signature and your request would promptly fail.
+	 *
+	 * @param   array   $headers
+	 * @param   string  $storageClass
+	 *
+	 * @return  void
+	 */
+	public static function setStorageClass(array &$headers, string $storageClass): void
+	{
+		// Remove all previously set X-Amz-Storage-Class headers (case-insensitive)
+		$killList = [];
+
+		foreach ($headers as $key => $value)
+		{
+			if (strtolower($key) === 'x-amz-storage-class')
+			{
+				$killList[] = $key;
+			}
+		}
+
+		foreach ($killList as $key)
+		{
+			unset($headers[$key]);
+		}
+
+		// Add the new X-Amz-Storage-Class header
+		$headers['X-Amz-Storage-Class'] = $storageClass;
+	}
+}

+ 35 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/akeeba/s3/src/aliasing.php

@@ -0,0 +1,35 @@
+<?php
+/**
+ * Akeeba Engine
+ *
+ * @package   akeebaengine
+ * @copyright Copyright (c)2006-2025 Nicholas K. Dionysopoulos / Akeeba Ltd
+ * @license   GNU General Public License version 3, or later
+ */
+
+/**
+ * Automatic aliasing of the old namespace to the new, as you use each old class.
+ */
+spl_autoload_register(
+	static function (string $className)
+	{
+		$oldNS = 'Akeeba\Engine\Postproc\Connector\S3v4';
+		$newNS = 'Akeeba\S3';
+
+		$className = trim($className, '\\');
+
+		if (strpos($className, $oldNS) !== 0)
+		{
+			return false;
+		}
+
+		$newClassName = $newNS . '\\' . trim(substr($className, strlen($oldNS)), '\\');
+
+		if (class_exists($newClassName, true))
+		{
+			class_alias($newClassName, $className, false);
+		}
+
+		return true;
+	}
+);

+ 7 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/autoload.php

@@ -0,0 +1,7 @@
+<?php
+
+// autoload.php @generated by Composer
+
+require_once __DIR__ . '/composer/autoload_real.php';
+
+return ComposerAutoloaderInitce7a171777b72b30ac424c26e34c53d7::getLoader();

+ 139 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/ClientAssertionType/HttpBasic.php

@@ -0,0 +1,139 @@
+<?php
+
+namespace OAuth2\ClientAssertionType;
+
+use OAuth2\Storage\ClientCredentialsInterface;
+use OAuth2\RequestInterface;
+use OAuth2\ResponseInterface;
+use LogicException;
+
+/**
+ * Validate a client via Http Basic authentication
+ *
+ * @author    Brent Shaffer <bshafs at gmail dot com>
+ */
+class HttpBasic implements ClientAssertionTypeInterface
+{
+    private $clientData;
+
+    protected $storage;
+    protected $config;
+
+    /**
+     * Config array $config should look as follows:
+     * @code
+     *     $config = array(
+     *         'allow_credentials_in_request_body' => true, // whether to look for credentials in the POST body in addition to the Authorize HTTP Header
+     *         'allow_public_clients'  => true              // if true, "public clients" (clients without a secret) may be authenticated
+     *     );
+     * @endcode
+     *
+     * @param ClientCredentialsInterface $storage Storage
+     * @param array                      $config  Configuration options for the server
+     */
+    public function __construct(ClientCredentialsInterface $storage, array $config = array())
+    {
+        $this->storage = $storage;
+        $this->config = array_merge(array(
+            'allow_credentials_in_request_body' => true,
+            'allow_public_clients' => true,
+        ), $config);
+    }
+
+    /**
+     * Validate the OAuth request
+     *
+     * @param RequestInterface $request
+     * @param ResponseInterface $response
+     * @return bool|mixed
+     * @throws LogicException
+     */
+    public function validateRequest(RequestInterface $request, ResponseInterface $response)
+    {
+        if (!$clientData = $this->getClientCredentials($request, $response)) {
+            return false;
+        }
+
+        if (!isset($clientData['client_id'])) {
+            throw new LogicException('the clientData array must have "client_id" set');
+        }
+
+        if (!isset($clientData['client_secret']) || $clientData['client_secret'] == '') {
+            if (!$this->config['allow_public_clients']) {
+                $response->setError(400, 'invalid_client', 'client credentials are required');
+
+                return false;
+            }
+
+            if (!$this->storage->isPublicClient($clientData['client_id'])) {
+                $response->setError(400, 'invalid_client', 'This client is invalid or must authenticate using a client secret');
+
+                return false;
+            }
+        } elseif ($this->storage->checkClientCredentials($clientData['client_id'], $clientData['client_secret']) === false) {
+            $response->setError(400, 'invalid_client', 'The client credentials are invalid');
+
+            return false;
+        }
+
+        $this->clientData = $clientData;
+
+        return true;
+    }
+
+    /**
+     * Get the client id
+     *
+     * @return mixed
+     */
+    public function getClientId()
+    {
+        return $this->clientData['client_id'];
+    }
+
+    /**
+     * Internal function used to get the client credentials from HTTP basic
+     * auth or POST data.
+     *
+     * According to the spec (draft 20), the client_id can be provided in
+     * the Basic Authorization header (recommended) or via GET/POST.
+     *
+     * @param RequestInterface  $request
+     * @param ResponseInterface $response
+     * @return array|null A list containing the client identifier and password, for example:
+     * @code
+     *     return array(
+     *         "client_id"     => CLIENT_ID,        // REQUIRED the client id
+     *         "client_secret" => CLIENT_SECRET,    // OPTIONAL the client secret (may be omitted for public clients)
+     *     );
+     * @endcode
+     *
+     * @see http://tools.ietf.org/html/rfc6749#section-2.3.1
+     *
+     * @ingroup oauth2_section_2
+     */
+    public function getClientCredentials(RequestInterface $request, ?ResponseInterface $response = null)
+    {
+        if (!is_null($request->headers('PHP_AUTH_USER')) && !is_null($request->headers('PHP_AUTH_PW'))) {
+            return array('client_id' => $request->headers('PHP_AUTH_USER'), 'client_secret' => $request->headers('PHP_AUTH_PW'));
+        }
+
+        if ($this->config['allow_credentials_in_request_body']) {
+            // Using POST for HttpBasic authorization is not recommended, but is supported by specification
+            if (!is_null($request->request('client_id'))) {
+                /**
+                 * client_secret can be null if the client's password is an empty string
+                 * @see http://tools.ietf.org/html/rfc6749#section-2.3.1
+                 */
+                return array('client_id' => $request->request('client_id'), 'client_secret' => $request->request('client_secret'));
+            }
+        }
+
+        if ($response) {
+            $message = $this->config['allow_credentials_in_request_body'] ? ' or body' : '';
+            $response->setError(400, 'invalid_client', 'Client credentials were not found in the headers'.$message);
+        }
+
+        return null;
+    }
+}

+ 481 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/Controller/AuthorizeController.php

@@ -0,0 +1,481 @@
+<?php
+
+namespace OAuth2\Controller;
+
+use OAuth2\Storage\ClientInterface;
+use OAuth2\ScopeInterface;
+use OAuth2\RequestInterface;
+use OAuth2\ResponseInterface;
+use OAuth2\Scope;
+use InvalidArgumentException;
+
+/**
+ * @see AuthorizeControllerInterface
+ */
+class AuthorizeController implements AuthorizeControllerInterface
+{
+    /**
+     * @var string
+     */
+    private $scope;
+
+    /**
+     * @var int
+     */
+    private $state;
+
+    /**
+     * @var mixed
+     */
+    private $client_id;
+
+    /**
+     * @var string
+     */
+    private $redirect_uri;
+
+    /**
+     * The response type
+     *
+     * @var string
+     */
+    private $response_type;
+
+    /**
+     * @var ClientInterface
+     */
+    protected $clientStorage;
+
+    /**
+     * @var array
+     */
+    protected $responseTypes;
+
+    /**
+     * @var array
+     */
+    protected $config;
+
+    /**
+     * @var ScopeInterface
+     */
+    protected $scopeUtil;
+
+    /**
+     * Constructor
+     *
+     * @param ClientInterface $clientStorage REQUIRED Instance of OAuth2\Storage\ClientInterface to retrieve client information
+     * @param array           $responseTypes OPTIONAL Array of OAuth2\ResponseType\ResponseTypeInterface objects.  Valid array
+     *                                       keys are "code" and "token"
+     * @param array           $config        OPTIONAL Configuration options for the server:
+     * @param ScopeInterface  $scopeUtil     OPTIONAL Instance of OAuth2\ScopeInterface to validate the requested scope
+     * @code
+     *     $config = array(
+     *         'allow_implicit' => false,            // if the controller should allow the "implicit" grant type
+     *         'enforce_state'  => true              // if the controller should require the "state" parameter
+     *         'require_exact_redirect_uri' => true, // if the controller should require an exact match on the "redirect_uri" parameter
+     *         'redirect_status_code' => 302,        // HTTP status code to use for redirect responses
+     *     );
+     * @endcode
+     */
+    public function __construct(ClientInterface $clientStorage, array $responseTypes = array(), array $config = array(), ?ScopeInterface $scopeUtil = null)
+    {
+        $this->clientStorage = $clientStorage;
+        $this->responseTypes = $responseTypes;
+        $this->config = array_merge(array(
+            'allow_implicit' => false,
+            'enforce_state'  => true,
+            'require_exact_redirect_uri' => true,
+            'redirect_status_code' => 302,
+            'enforce_pkce' => false,
+        ), $config);
+
+        if (is_null($scopeUtil)) {
+            $scopeUtil = new Scope();
+        }
+        $this->scopeUtil = $scopeUtil;
+    }
+
+    /**
+     * Handle the authorization request
+     *
+     * @param RequestInterface  $request
+     * @param ResponseInterface $response
+     * @param boolean           $is_authorized
+     * @param mixed             $user_id
+     * @return mixed|void
+     * @throws InvalidArgumentException
+     */
+    public function handleAuthorizeRequest(RequestInterface $request, ResponseInterface $response, $is_authorized, $user_id = null)
+    {
+        if (!is_bool($is_authorized)) {
+            throw new InvalidArgumentException('Argument "is_authorized" must be a boolean.  This method must know if the user has granted access to the client.');
+        }
+
+        // We repeat this, because we need to re-validate. The request could be POSTed
+        // by a 3rd-party (because we are not internally enforcing NONCEs, etc)
+        if (!$this->validateAuthorizeRequest($request, $response)) {
+            return;
+        }
+
+        // If no redirect_uri is passed in the request, use client's registered one
+        if (empty($this->redirect_uri)) {
+            $clientData              = $this->clientStorage->getClientDetails($this->client_id);
+            $registered_redirect_uri = $clientData['redirect_uri'];
+        }
+
+        // the user declined access to the client's application
+        if ($is_authorized === false) {
+            $redirect_uri = $this->redirect_uri ?: $registered_redirect_uri;
+            $this->setNotAuthorizedResponse($request, $response, $redirect_uri, $user_id);
+
+            return;
+        }
+
+        // build the parameters to set in the redirect URI
+        if (!$params = $this->buildAuthorizeParameters($request, $response, $user_id)) {
+            return;
+        }
+
+        $authResult = $this->responseTypes[$this->response_type]->getAuthorizeResponse($params, $user_id);
+
+        list($redirect_uri, $uri_params) = $authResult;
+
+        if (empty($redirect_uri) && !empty($registered_redirect_uri)) {
+            $redirect_uri = $registered_redirect_uri;
+        }
+
+        $uri = $this->buildUri($redirect_uri, $uri_params);
+
+        // return redirect response
+        $response->setRedirect($this->config['redirect_status_code'], $uri);
+    }
+
+    /**
+     * Set not authorized response
+     *
+     * @param RequestInterface  $request
+     * @param ResponseInterface $response
+     * @param string            $redirect_uri
+     * @param mixed             $user_id
+     */
+    protected function setNotAuthorizedResponse(RequestInterface $request, ResponseInterface $response, $redirect_uri, $user_id = null)
+    {
+        $error = 'access_denied';
+        $error_message = 'The user denied access to your application';
+        $response->setRedirect($this->config['redirect_status_code'], $redirect_uri, $this->state, $error, $error_message);
+    }
+
+    /**
+     * We have made this protected so this class can be extended to add/modify
+     * these parameters
+     *
+     * @TODO: add dependency injection for the parameters in this method
+     *
+     * @param RequestInterface $request
+     * @param ResponseInterface $response
+     * @param mixed $user_id
+     * @return array
+     */
+    protected function buildAuthorizeParameters($request, $response, $user_id)
+    {
+        // @TODO: we should be explicit with this in the future
+        $params = array(
+            'scope'         => $this->scope,
+            'state'         => $this->state,
+            'client_id'     => $this->client_id,
+            'redirect_uri'  => $this->redirect_uri,
+            'response_type' => $this->response_type,
+        );
+
+        return $params;
+    }
+
+    /**
+     * Validate the OAuth request
+     *
+     * @param RequestInterface $request
+     * @param ResponseInterface $response
+     * @return bool
+     */
+    public function validateAuthorizeRequest(RequestInterface $request, ResponseInterface $response)
+    {
+        // Make sure a valid client id was supplied (we can not redirect because we were unable to verify the URI)
+        if (!$client_id = $request->query('client_id', $request->request('client_id'))) {
+            // We don't have a good URI to use
+            $response->setError(400, 'invalid_client', "No client id supplied");
+
+            return false;
+        }
+
+        // Get client details
+        if (!$clientData = $this->clientStorage->getClientDetails($client_id)) {
+            $response->setError(400, 'invalid_client', 'The client id supplied is invalid');
+
+            return false;
+        }
+
+        $registered_redirect_uri = isset($clientData['redirect_uri']) ? $clientData['redirect_uri'] : '';
+
+        // Make sure a valid redirect_uri was supplied. If specified, it must match the clientData URI.
+        // @see http://tools.ietf.org/html/rfc6749#section-3.1.2
+        // @see http://tools.ietf.org/html/rfc6749#section-4.1.2.1
+        // @see http://tools.ietf.org/html/rfc6749#section-4.2.2.1
+        if ($supplied_redirect_uri = $request->query('redirect_uri', $request->request('redirect_uri'))) {
+            // validate there is no fragment supplied
+            $parts = parse_url($supplied_redirect_uri);
+            if (isset($parts['fragment']) && $parts['fragment']) {
+                $response->setError(400, 'invalid_uri', 'The redirect URI must not contain a fragment');
+
+                return false;
+            }
+
+            // validate against the registered redirect uri(s) if available
+            if ($registered_redirect_uri && !$this->validateRedirectUri($supplied_redirect_uri, $registered_redirect_uri)) {
+                $response->setError(400, 'redirect_uri_mismatch', 'The redirect URI provided is missing or does not match', '#section-3.1.2');
+
+                return false;
+            }
+            $redirect_uri = $supplied_redirect_uri;
+        } else {
+            // use the registered redirect_uri if none has been supplied, if possible
+            if (!$registered_redirect_uri) {
+                $response->setError(400, 'invalid_uri', 'No redirect URI was supplied or stored');
+
+                return false;
+            }
+
+            if (count(explode(' ', $registered_redirect_uri)) > 1) {
+                $response->setError(400, 'invalid_uri', 'A redirect URI must be supplied when multiple redirect URIs are registered', '#section-3.1.2.3');
+
+                return false;
+            }
+            $redirect_uri = $registered_redirect_uri;
+        }
+
+        // Select the response type
+        $response_type = $request->query('response_type', $request->request('response_type'));
+
+        // for multiple-valued response types - make them alphabetical
+        if (false !== strpos($response_type, ' ')) {
+            $types = explode(' ', $response_type);
+            sort($types);
+            $response_type = ltrim(implode(' ', $types));
+        }
+
+        $state = $request->query('state', $request->request('state'));
+
+        // type and client_id are required
+        if (!$response_type || !in_array($response_type, $this->getValidResponseTypes())) {
+            $response->setRedirect($this->config['redirect_status_code'], $redirect_uri, $state, 'invalid_request', 'Invalid or missing response type', null);
+
+            return false;
+        }
+
+        if ($response_type == self::RESPONSE_TYPE_AUTHORIZATION_CODE) {
+            if (!isset($this->responseTypes['code'])) {
+                $response->setRedirect($this->config['redirect_status_code'], $redirect_uri, $state, 'unsupported_response_type', 'authorization code grant type not supported', null);
+
+                return false;
+            }
+            if (!$this->clientStorage->checkRestrictedGrantType($client_id, 'authorization_code')) {
+                $response->setRedirect($this->config['redirect_status_code'], $redirect_uri, $state, 'unauthorized_client', 'The grant type is unauthorized for this client_id', null);
+
+                return false;
+            }
+            if ($this->responseTypes['code']->enforceRedirect() && !$redirect_uri) {
+                $response->setError(400, 'redirect_uri_mismatch', 'The redirect URI is mandatory and was not supplied');
+
+                return false;
+            }
+        } else {
+            if (!$this->config['allow_implicit']) {
+                $response->setRedirect($this->config['redirect_status_code'], $redirect_uri, $state, 'unsupported_response_type', 'implicit grant type not supported', null);
+
+                return false;
+            }
+            if (!$this->clientStorage->checkRestrictedGrantType($client_id, 'implicit')) {
+                $response->setRedirect($this->config['redirect_status_code'], $redirect_uri, $state, 'unauthorized_client', 'The grant type is unauthorized for this client_id', null);
+
+                return false;
+            }
+        }
+
+        // validate requested scope if it exists
+        $requestedScope = $this->scopeUtil->getScopeFromRequest($request);
+
+        if ($requestedScope) {
+            // restrict scope by client specific scope if applicable,
+            // otherwise verify the scope exists
+            $clientScope = $this->clientStorage->getClientScope($client_id);
+            if ((empty($clientScope) && !$this->scopeUtil->scopeExists($requestedScope))
+                || (!empty($clientScope) && !$this->scopeUtil->checkScope($requestedScope, $clientScope))) {
+                $response->setRedirect($this->config['redirect_status_code'], $redirect_uri, $state, 'invalid_scope', 'An unsupported scope was requested', null);
+
+                return false;
+            }
+        } else {
+            // use a globally-defined default scope
+            $defaultScope = $this->scopeUtil->getDefaultScope($client_id);
+
+            if (false === $defaultScope) {
+                $response->setRedirect($this->config['redirect_status_code'], $redirect_uri, $state, 'invalid_client', 'This application requires you specify a scope parameter', null);
+
+                return false;
+            }
+
+            $requestedScope = $defaultScope;
+        }
+
+        // Validate state parameter exists (if configured to enforce this)
+        if ($this->config['enforce_state'] && !$state) {
+            $response->setRedirect($this->config['redirect_status_code'], $redirect_uri, null, 'invalid_request', 'The state parameter is required');
+
+            return false;
+        }
+
+        // save the input data and return true
+        $this->scope         = $requestedScope;
+        $this->state         = $state;
+        $this->client_id     = $client_id;
+        // Only save the SUPPLIED redirect URI (@see http://tools.ietf.org/html/rfc6749#section-4.1.3)
+        $this->redirect_uri  = $supplied_redirect_uri;
+        $this->response_type = $response_type;
+
+        return true;
+    }
+
+    /**
+     * Build the absolute URI based on supplied URI and parameters.
+     *
+     * @param string $uri    An absolute URI.
+     * @param array  $params Parameters to be append as GET.
+     *
+     * @return string
+     * An absolute URI with supplied parameters.
+     *
+     * @ingroup oauth2_section_4
+     */
+    private function buildUri($uri, $params)
+    {
+        $parse_url = parse_url($uri);
+
+        // Add our params to the parsed uri
+        foreach ($params as $k => $v) {
+            if (isset($parse_url[$k])) {
+                $parse_url[$k] .= "&" . http_build_query($v, '', '&');
+            } else {
+                $parse_url[$k] = http_build_query($v, '', '&');
+            }
+        }
+
+        // Put the uri back together
+        return
+              ((isset($parse_url["scheme"])) ? $parse_url["scheme"] . "://" : "")
+            . ((isset($parse_url["user"])) ? $parse_url["user"]
+            . ((isset($parse_url["pass"])) ? ":" . $parse_url["pass"] : "") . "@" : "")
+            . ((isset($parse_url["host"])) ? $parse_url["host"] : "")
+            . ((isset($parse_url["port"])) ? ":" . $parse_url["port"] : "")
+            . ((isset($parse_url["path"])) ? $parse_url["path"] : "")
+            . ((isset($parse_url["query"]) && !empty($parse_url['query'])) ? "?" . $parse_url["query"] : "")
+            . ((isset($parse_url["fragment"])) ? "#" . $parse_url["fragment"] : "")
+        ;
+    }
+
+    protected function getValidResponseTypes()
+    {
+        return array(
+            self::RESPONSE_TYPE_ACCESS_TOKEN,
+            self::RESPONSE_TYPE_AUTHORIZATION_CODE,
+        );
+    }
+
+    /**
+     * Internal method for validating redirect URI supplied
+     *
+     * @param string $inputUri The submitted URI to be validated
+     * @param string $registeredUriString The allowed URI(s) to validate against.  Can be a space-delimited string of URIs to
+     *                                    allow for multiple URIs
+     * @return bool
+     * @see http://tools.ietf.org/html/rfc6749#section-3.1.2
+     */
+    protected function validateRedirectUri($inputUri, $registeredUriString)
+    {
+        if (!$inputUri || !$registeredUriString) {
+            return false; // if either one is missing, assume INVALID
+        }
+
+        $registered_uris = preg_split('/\s+/', $registeredUriString);
+        foreach ($registered_uris as $registered_uri) {
+            if ($this->config['require_exact_redirect_uri']) {
+                // the input uri is validated against the registered uri using exact match
+                if (strcmp($inputUri, $registered_uri) === 0) {
+                    return true;
+                }
+            } else {
+                $registered_uri_length = strlen($registered_uri);
+                if ($registered_uri_length === 0) {
+                    return false;
+                }
+
+                // the input uri is validated against the registered uri using case-insensitive match of the initial string
+                // i.e. additional query parameters may be applied
+                if (strcasecmp(substr($inputUri, 0, $registered_uri_length), $registered_uri) === 0) {
+                    return true;
+                }
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * Convenience method to access the scope
+     *
+     * @return string
+     */
+    public function getScope()
+    {
+        return $this->scope;
+    }
+
+    /**
+     * Convenience method to access the state
+     *
+     * @return int
+     */
+    public function getState()
+    {
+        return $this->state;
+    }
+
+    /**
+     * Convenience method to access the client id
+     *
+     * @return mixed
+     */
+    public function getClientId()
+    {
+        return $this->client_id;
+    }
+
+    /**
+     * Convenience method to access the redirect url
+     *
+     * @return string
+     */
+    public function getRedirectUri()
+    {
+        return $this->redirect_uri;
+    }
+
+    /**
+     * Convenience method to access the response type
+     *
+     * @return string
+     */
+    public function getResponseType()
+    {
+        return $this->response_type;
+    }
+}

+ 156 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/Controller/ResourceController.php

@@ -0,0 +1,156 @@
+<?php
+
+namespace OAuth2\Controller;
+
+use OAuth2\TokenType\TokenTypeInterface;
+use OAuth2\Storage\AccessTokenInterface;
+use OAuth2\ScopeInterface;
+use OAuth2\RequestInterface;
+use OAuth2\ResponseInterface;
+use OAuth2\Scope;
+
+/**
+ * @see ResourceControllerInterface
+ */
+class ResourceController implements ResourceControllerInterface
+{
+    /**
+     * @var array
+     */
+    private $token;
+
+    /**
+     * @var TokenTypeInterface
+     */
+    protected $tokenType;
+
+    /**
+     * @var AccessTokenInterface
+     */
+    protected $tokenStorage;
+
+    /**
+     * @var array
+     */
+    protected $config;
+
+    /**
+     * @var ScopeInterface
+     */
+    protected $scopeUtil;
+
+    /**
+     * Constructor
+     *
+     * @param TokenTypeInterface   $tokenType
+     * @param AccessTokenInterface $tokenStorage
+     * @param array                $config
+     * @param ScopeInterface       $scopeUtil
+     */
+    public function __construct(TokenTypeInterface $tokenType, AccessTokenInterface $tokenStorage, $config = array(), ?ScopeInterface $scopeUtil = null)
+    {
+        $this->tokenType = $tokenType;
+        $this->tokenStorage = $tokenStorage;
+
+        $this->config = array_merge(array(
+            'www_realm' => 'Service',
+        ), $config);
+
+        if (is_null($scopeUtil)) {
+            $scopeUtil = new Scope();
+        }
+        $this->scopeUtil = $scopeUtil;
+    }
+
+    /**
+     * Verify the resource request
+     *
+     * @param RequestInterface  $request
+     * @param ResponseInterface $response
+     * @param null              $scope
+     * @return bool
+     */
+    public function verifyResourceRequest(RequestInterface $request, ResponseInterface $response, $scope = null)
+    {
+        $token = $this->getAccessTokenData($request, $response);
+
+        // Check if we have token data
+        if (is_null($token)) {
+            return false;
+        }
+
+        /**
+         * Check scope, if provided
+         * If token doesn't have a scope, it's null/empty, or it's insufficient, then throw 403
+         * @see http://tools.ietf.org/html/rfc6750#section-3.1
+         */
+        if ($scope && (!isset($token["scope"]) || !$token["scope"] || !$this->scopeUtil->checkScope($scope, $token["scope"]))) {
+            $response->setError(403, 'insufficient_scope', 'The request requires higher privileges than provided by the access token');
+            $response->addHttpHeaders(array(
+                'WWW-Authenticate' => sprintf('%s realm="%s", scope="%s", error="%s", error_description="%s"',
+                    $this->tokenType->getTokenType(),
+                    $this->config['www_realm'],
+                    $scope,
+                    $response->getParameter('error'),
+                    $response->getParameter('error_description')
+                )
+            ));
+
+            return false;
+        }
+
+        // allow retrieval of the token
+        $this->token = $token;
+
+        return (bool) $token;
+    }
+
+    /**
+     * Get access token data.
+     *
+     * @param RequestInterface  $request
+     * @param ResponseInterface $response
+     * @return array|null
+     */
+    public function getAccessTokenData(RequestInterface $request, ResponseInterface $response)
+    {
+        // Get the token parameter
+        if ($token_param = $this->tokenType->getAccessTokenParameter($request, $response)) {
+            // Get the stored token data (from the implementing subclass)
+            // Check we have a well formed token
+            // Check token expiration (expires is a mandatory paramter)
+            if (!$token = $this->tokenStorage->getAccessToken($token_param)) {
+                $response->setError(401, 'invalid_token', 'The access token provided is invalid');
+            } elseif (!isset($token["expires"]) || !isset($token["client_id"])) {
+                $response->setError(401, 'malformed_token', 'Malformed token (missing "expires")');
+            } elseif (time() > $token["expires"]) {
+                $response->setError(401, 'invalid_token', 'The access token provided has expired');
+            } else {
+                return $token;
+            }
+        }
+
+        $authHeader = sprintf('%s realm="%s"', $this->tokenType->getTokenType(), $this->config['www_realm']);
+
+        if ($error = $response->getParameter('error')) {
+            $authHeader = sprintf('%s, error="%s"', $authHeader, $error);
+            if ($error_description = $response->getParameter('error_description')) {
+                $authHeader = sprintf('%s, error_description="%s"', $authHeader, $error_description);
+            }
+        }
+
+        $response->addHttpHeaders(array('WWW-Authenticate' => $authHeader));
+
+        return null;
+    }
+
+    /**
+     * convenience method to allow retrieval of the token.
+     *
+     * @return array
+     */
+    public function getToken()
+    {
+        return $this->token;
+    }
+}

+ 333 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/Controller/TokenController.php

@@ -0,0 +1,333 @@
+<?php
+
+namespace OAuth2\Controller;
+
+use OAuth2\ResponseType\AccessTokenInterface;
+use OAuth2\ClientAssertionType\ClientAssertionTypeInterface;
+use OAuth2\GrantType\GrantTypeInterface;
+use OAuth2\ScopeInterface;
+use OAuth2\Scope;
+use OAuth2\Storage\ClientInterface;
+use OAuth2\RequestInterface;
+use OAuth2\ResponseInterface;
+use InvalidArgumentException;
+use LogicException;
+use RuntimeException;
+
+/**
+ * @see TokenControllerInterface
+ */
+class TokenController implements TokenControllerInterface
+{
+    /**
+     * @var AccessTokenInterface
+     */
+    protected $accessToken;
+
+    /**
+     * @var array<GrantTypeInterface>
+     */
+    protected $grantTypes;
+
+    /**
+     * @var ClientAssertionTypeInterface
+     */
+    protected $clientAssertionType;
+
+    /**
+     * @var ScopeInterface
+     */
+    protected $scopeUtil;
+
+    /**
+     * @var ClientInterface
+     */
+    protected $clientStorage;
+
+    /**
+     * Constructor
+     *
+     * @param AccessTokenInterface         $accessToken
+     * @param ClientInterface              $clientStorage
+     * @param array                        $grantTypes
+     * @param ClientAssertionTypeInterface $clientAssertionType
+     * @param ScopeInterface               $scopeUtil
+     * @throws InvalidArgumentException
+     */
+    public function __construct(AccessTokenInterface $accessToken, ClientInterface $clientStorage, array $grantTypes = array(), ?ClientAssertionTypeInterface $clientAssertionType = null, ?ScopeInterface $scopeUtil = null)
+    {
+        if (is_null($clientAssertionType)) {
+            foreach ($grantTypes as $grantType) {
+                if (!$grantType instanceof ClientAssertionTypeInterface) {
+                    throw new InvalidArgumentException('You must supply an instance of OAuth2\ClientAssertionType\ClientAssertionTypeInterface or only use grant types which implement OAuth2\ClientAssertionType\ClientAssertionTypeInterface');
+                }
+            }
+        }
+        $this->clientAssertionType = $clientAssertionType;
+        $this->accessToken = $accessToken;
+        $this->clientStorage = $clientStorage;
+        foreach ($grantTypes as $grantType) {
+            $this->addGrantType($grantType);
+        }
+
+        if (is_null($scopeUtil)) {
+            $scopeUtil = new Scope();
+        }
+        $this->scopeUtil = $scopeUtil;
+    }
+
+    /**
+     * Handle the token request.
+     *
+     * @param RequestInterface  $request  - Request object to grant access token
+     * @param ResponseInterface $response - Response object
+     */
+    public function handleTokenRequest(RequestInterface $request, ResponseInterface $response)
+    {
+        if ($token = $this->grantAccessToken($request, $response)) {
+            // @see http://tools.ietf.org/html/rfc6749#section-5.1
+            // server MUST disable caching in headers when tokens are involved
+            $response->setStatusCode(200);
+            $response->addParameters($token);
+            $response->addHttpHeaders(array(
+                'Cache-Control' => 'no-store',
+                'Pragma' => 'no-cache',
+                'Content-Type' => 'application/json'
+            ));
+        }
+    }
+
+    /**
+     * Grant or deny a requested access token.
+     * This would be called from the "/token" endpoint as defined in the spec.
+     * You can call your endpoint whatever you want.
+     *
+     * @param RequestInterface  $request  - Request object to grant access token
+     * @param ResponseInterface $response - Response object
+     *
+     * @return bool|null|array
+     *
+     * @throws \InvalidArgumentException
+     * @throws \LogicException
+     *
+     * @see http://tools.ietf.org/html/rfc6749#section-4
+     * @see http://tools.ietf.org/html/rfc6749#section-10.6
+     * @see http://tools.ietf.org/html/rfc6749#section-4.1.3
+     *
+     * @ingroup oauth2_section_4
+     */
+    public function grantAccessToken(RequestInterface $request, ResponseInterface $response)
+    {
+        if (strtolower($request->server('REQUEST_METHOD')) === 'options') {
+            $response->addHttpHeaders(array('Allow' => 'POST, OPTIONS'));
+
+            return null;
+        }
+
+        if (strtolower($request->server('REQUEST_METHOD')) !== 'post') {
+            $response->setError(405, 'invalid_request', 'The request method must be POST when requesting an access token', '#section-3.2');
+            $response->addHttpHeaders(array('Allow' => 'POST, OPTIONS'));
+
+            return null;
+        }
+
+        /**
+         * Determine grant type from request
+         * and validate the request for that grant type
+         */
+        if (!$grantTypeIdentifier = $request->request('grant_type')) {
+            $response->setError(400, 'invalid_request', 'The grant type was not specified in the request');
+
+            return null;
+        }
+
+        if (!isset($this->grantTypes[$grantTypeIdentifier])) {
+            /* TODO: If this is an OAuth2 supported grant type that we have chosen not to implement, throw a 501 Not Implemented instead */
+            $response->setError(400, 'unsupported_grant_type', sprintf('Grant type "%s" not supported', $grantTypeIdentifier));
+
+            return null;
+        }
+
+        /** @var GrantTypeInterface $grantType */
+        $grantType = $this->grantTypes[$grantTypeIdentifier];
+
+        /**
+         * Retrieve the client information from the request
+         * ClientAssertionTypes allow for grant types which also assert the client data
+         * in which case ClientAssertion is handled in the validateRequest method
+         *
+         * @see \OAuth2\GrantType\JWTBearer
+         * @see \OAuth2\GrantType\ClientCredentials
+         */
+        if (!$grantType instanceof ClientAssertionTypeInterface) {
+            if (!$this->clientAssertionType->validateRequest($request, $response)) {
+                return null;
+            }
+            $clientId = $this->clientAssertionType->getClientId();
+        }
+
+        /**
+         * Retrieve the grant type information from the request
+         * The GrantTypeInterface object handles all validation
+         * If the object is an instance of ClientAssertionTypeInterface,
+         * That logic is handled here as well
+         */
+        if (!$grantType->validateRequest($request, $response)) {
+            return null;
+        }
+
+        if ($grantType instanceof ClientAssertionTypeInterface) {
+            $clientId = $grantType->getClientId();
+        } else {
+            // validate the Client ID (if applicable)
+            if (!is_null($storedClientId = $grantType->getClientId()) && $storedClientId != $clientId) {
+                $response->setError(400, 'invalid_grant', sprintf('%s doesn\'t exist or is invalid for the client', $grantTypeIdentifier));
+
+                return null;
+            }
+        }
+
+        /**
+         * Validate the client can use the requested grant type
+         */
+        if (!$this->clientStorage->checkRestrictedGrantType($clientId, $grantTypeIdentifier)) {
+            $response->setError(400, 'unauthorized_client', 'The grant type is unauthorized for this client_id');
+
+            return false;
+        }
+
+        /**
+         * Validate the scope of the token
+         *
+         * requestedScope - the scope specified in the token request
+         * availableScope - the scope associated with the grant type
+         *  ex: in the case of the "Authorization Code" grant type,
+         *  the scope is specified in the authorize request
+         *
+         * @see http://tools.ietf.org/html/rfc6749#section-3.3
+         */
+        $requestedScope = $this->scopeUtil->getScopeFromRequest($request);
+        $availableScope = $grantType->getScope();
+
+        if ($requestedScope) {
+            // validate the requested scope
+            if ($availableScope) {
+                if (!$this->scopeUtil->checkScope($requestedScope, $availableScope)) {
+                    $response->setError(400, 'invalid_scope', 'The scope requested is invalid for this request');
+
+                    return null;
+                }
+            } else {
+                // validate the client has access to this scope
+                if ($clientScope = $this->clientStorage->getClientScope($clientId)) {
+                    if (!$this->scopeUtil->checkScope($requestedScope, $clientScope)) {
+                        $response->setError(400, 'invalid_scope', 'The scope requested is invalid for this client');
+
+                        return false;
+                    }
+                } elseif (!$this->scopeUtil->scopeExists($requestedScope)) {
+                    $response->setError(400, 'invalid_scope', 'An unsupported scope was requested');
+
+                    return null;
+                }
+            }
+        } elseif ($availableScope) {
+            // use the scope associated with this grant type
+            $requestedScope = $availableScope;
+        } else {
+            // use a globally-defined default scope
+            $defaultScope = $this->scopeUtil->getDefaultScope($clientId);
+
+            // "false" means default scopes are not allowed
+            if (false === $defaultScope) {
+                $response->setError(400, 'invalid_scope', 'This application requires you specify a scope parameter');
+
+                return null;
+            }
+
+            $requestedScope = $defaultScope;
+        }
+
+        return $grantType->createAccessToken($this->accessToken, $clientId, $grantType->getUserId(), $requestedScope);
+    }
+
+    /**
+     * Add grant type
+     *
+     * @param GrantTypeInterface $grantType  - the grant type to add for the specified identifier
+     * @param string|null        $identifier - a string passed in as "grant_type" in the response that will call this grantType
+     */
+    public function addGrantType(GrantTypeInterface $grantType, $identifier = null)
+    {
+        if (is_null($identifier) || is_numeric($identifier)) {
+            $identifier = $grantType->getQueryStringIdentifier();
+        }
+
+        $this->grantTypes[$identifier] = $grantType;
+    }
+
+    /**
+     * @param RequestInterface  $request
+     * @param ResponseInterface $response
+     */
+    public function handleRevokeRequest(RequestInterface $request, ResponseInterface $response)
+    {
+        if ($this->revokeToken($request, $response)) {
+            $response->setStatusCode(200);
+            $response->addParameters(array('revoked' => true));
+        }
+    }
+
+    /**
+     * Revoke a refresh or access token. Returns true on success and when tokens are invalid
+     *
+     * Note: invalid tokens do not cause an error response since the client
+     * cannot handle such an error in a reasonable way.  Moreover, the
+     * purpose of the revocation request, invalidating the particular token,
+     * is already achieved.
+     *
+     * @param RequestInterface $request
+     * @param ResponseInterface $response
+     * @throws RuntimeException
+     * @return bool|null
+     */
+    public function revokeToken(RequestInterface $request, ResponseInterface $response)
+    {
+        if (strtolower($request->server('REQUEST_METHOD')) === 'options') {
+            $response->addHttpHeaders(array('Allow' => 'POST, OPTIONS'));
+
+            return null;
+        }
+
+        if (strtolower($request->server('REQUEST_METHOD')) !== 'post') {
+            $response->setError(405, 'invalid_request', 'The request method must be POST when revoking an access token', '#section-3.2');
+            $response->addHttpHeaders(array('Allow' => 'POST, OPTIONS'));
+
+            return null;
+        }
+
+        $token_type_hint = $request->request('token_type_hint');
+        if (!in_array($token_type_hint, array(null, 'access_token', 'refresh_token'), true)) {
+            $response->setError(400, 'invalid_request', 'Token type hint must be either \'access_token\' or \'refresh_token\'');
+
+            return null;
+        }
+
+        $token = $request->request('token');
+        if ($token === null) {
+            $response->setError(400, 'invalid_request', 'Missing token parameter to revoke');
+
+            return null;
+        }
+
+        // @todo remove this check for v2.0
+        if (!method_exists($this->accessToken, 'revokeToken')) {
+            $class = get_class($this->accessToken);
+            throw new RuntimeException("AccessToken {$class} does not implement required revokeToken method");
+        }
+
+        $this->accessToken->revokeToken($token, $token_type_hint);
+
+        return true;
+    }
+}

+ 247 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/GrantType/JwtBearer.php

@@ -0,0 +1,247 @@
+<?php
+
+namespace OAuth2\GrantType;
+
+use OAuth2\ClientAssertionType\ClientAssertionTypeInterface;
+use OAuth2\Storage\JwtBearerInterface;
+use OAuth2\Encryption\Jwt;
+use OAuth2\Encryption\EncryptionInterface;
+use OAuth2\ResponseType\AccessTokenInterface;
+use OAuth2\RequestInterface;
+use OAuth2\ResponseInterface;
+
+/**
+ * The JWT bearer authorization grant implements JWT (JSON Web Tokens) as a grant type per the IETF draft.
+ *
+ * @see http://tools.ietf.org/html/draft-ietf-oauth-jwt-bearer-04#section-4
+ *
+ * @author F21
+ * @author Brent Shaffer <bshafs at gmail dot com>
+ */
+class JwtBearer implements GrantTypeInterface, ClientAssertionTypeInterface
+{
+    private $jwt;
+
+    protected $storage;
+    protected $audience;
+    protected $jwtUtil;
+    protected $allowedAlgorithms;
+
+    /**
+     * Creates an instance of the JWT bearer grant type.
+     *
+     * @param JwtBearerInterface      $storage  - A valid storage interface that implements storage hooks for the JWT
+     *                                            bearer grant type.
+     * @param string                  $audience - The audience to validate the token against. This is usually the full
+     *                                            URI of the OAuth token requests endpoint.
+     * @param EncryptionInterface|JWT $jwtUtil  - OPTONAL The class used to decode, encode and verify JWTs.
+     * @param array                   $config
+     */
+    public function __construct(JwtBearerInterface $storage, $audience, ?EncryptionInterface $jwtUtil = null, array $config = array())
+    {
+        $this->storage = $storage;
+        $this->audience = $audience;
+
+        if (is_null($jwtUtil)) {
+            $jwtUtil = new Jwt();
+        }
+
+        $config = array_merge(array(
+            'allowed_algorithms' => array('RS256', 'RS384', 'RS512')
+        ), $config);
+
+        $this->jwtUtil = $jwtUtil;
+
+        $this->allowedAlgorithms = $config['allowed_algorithms'];
+    }
+
+    /**
+     * Returns the grant_type get parameter to identify the grant type request as JWT bearer authorization grant.
+     *
+     * @return string - The string identifier for grant_type.
+     *
+     * @see GrantTypeInterface::getQueryStringIdentifier()
+     */
+    public function getQueryStringIdentifier()
+    {
+        return 'urn:ietf:params:oauth:grant-type:jwt-bearer';
+    }
+
+    /**
+     * Validates the data from the decoded JWT.
+     *
+     * @param RequestInterface  $request
+     * @param ResponseInterface $response
+     * @return bool|mixed|null TRUE if the JWT request is valid and can be decoded. Otherwise, FALSE is returned.@see GrantTypeInterface::getTokenData()
+     */
+    public function validateRequest(RequestInterface $request, ResponseInterface $response)
+    {
+        if (!$request->request("assertion")) {
+            $response->setError(400, 'invalid_request', 'Missing parameters: "assertion" required');
+
+            return null;
+        }
+
+        // Store the undecoded JWT for later use
+        $undecodedJWT = $request->request('assertion');
+
+        // Decode the JWT
+        $jwt = $this->jwtUtil->decode($request->request('assertion'), null, false);
+
+        if (!$jwt) {
+            $response->setError(400, 'invalid_request', "JWT is malformed");
+
+            return null;
+        }
+
+        // ensure these properties contain a value
+        // @todo: throw malformed error for missing properties
+        $jwt = array_merge(array(
+            'scope' => null,
+            'iss' => null,
+            'sub' => null,
+            'aud' => null,
+            'exp' => null,
+            'nbf' => null,
+            'iat' => null,
+            'jti' => null,
+            'typ' => null,
+        ), $jwt);
+
+        if (!isset($jwt['iss'])) {
+            $response->setError(400, 'invalid_grant', "Invalid issuer (iss) provided");
+
+            return null;
+        }
+
+        if (!isset($jwt['sub'])) {
+            $response->setError(400, 'invalid_grant', "Invalid subject (sub) provided");
+
+            return null;
+        }
+
+        if (!isset($jwt['exp'])) {
+            $response->setError(400, 'invalid_grant', "Expiration (exp) time must be present");
+
+            return null;
+        }
+
+        // Check expiration
+        if (ctype_digit((string)$jwt['exp'])) {
+            if ($jwt['exp'] <= time()) {
+                $response->setError(400, 'invalid_grant', "JWT has expired");
+
+                return null;
+            }
+        } else {
+            $response->setError(400, 'invalid_grant', "Expiration (exp) time must be a unix time stamp");
+
+            return null;
+        }
+
+        // Check the not before time
+        if ($notBefore = $jwt['nbf']) {
+            if (ctype_digit((string)$notBefore)) {
+                if ($notBefore > time()) {
+                    $response->setError(400, 'invalid_grant', "JWT cannot be used before the Not Before (nbf) time");
+
+                    return null;
+                }
+            } else {
+                $response->setError(400, 'invalid_grant', "Not Before (nbf) time must be a unix time stamp");
+
+                return null;
+            }
+        }
+
+        // Check the audience if required to match
+        if (!isset($jwt['aud']) || ($jwt['aud'] != $this->audience)) {
+            $response->setError(400, 'invalid_grant', "Invalid audience (aud)");
+
+            return null;
+        }
+
+        // Check the jti (nonce)
+        // @see http://tools.ietf.org/html/draft-ietf-oauth-json-web-token-13#section-4.1.7
+        if (isset($jwt['jti'])) {
+            $jti = $this->storage->getJti($jwt['iss'], $jwt['sub'], $jwt['aud'], $jwt['exp'], $jwt['jti']);
+
+            //Reject if jti is used and jwt is still valid (exp parameter has not expired).
+            if ($jti && $jti['expires'] > time()) {
+                $response->setError(400, 'invalid_grant', "JSON Token Identifier (jti) has already been used");
+
+                return null;
+            } else {
+                $this->storage->setJti($jwt['iss'], $jwt['sub'], $jwt['aud'], $jwt['exp'], $jwt['jti']);
+            }
+        }
+
+        // Get the iss's public key
+        // @see http://tools.ietf.org/html/draft-ietf-oauth-json-web-token-06#section-4.1.1
+        if (!$key = $this->storage->getClientKey($jwt['iss'], $jwt['sub'])) {
+            $response->setError(400, 'invalid_grant', "Invalid issuer (iss) or subject (sub) provided");
+
+            return null;
+        }
+
+        // Verify the JWT
+        if (!$this->jwtUtil->decode($undecodedJWT, $key, $this->allowedAlgorithms)) {
+            $response->setError(400, 'invalid_grant', "JWT failed signature verification");
+
+            return null;
+        }
+
+        $this->jwt = $jwt;
+
+        return true;
+    }
+
+    /**
+     * Get client id
+     *
+     * @return mixed
+     */
+    public function getClientId()
+    {
+        return $this->jwt['iss'];
+    }
+
+    /**
+     * Get user id
+     *
+     * @return mixed
+     */
+    public function getUserId()
+    {
+        return $this->jwt['sub'];
+    }
+
+    /**
+     * Get scope
+     *
+     * @return null
+     */
+    public function getScope()
+    {
+        return null;
+    }
+
+    /**
+     * Creates an access token that is NOT associated with a refresh token.
+     * If a subject (sub) the name of the user/account we are accessing data on behalf of.
+     *
+     * @see GrantTypeInterface::createAccessToken()
+     *
+     * @param AccessTokenInterface $accessToken
+     * @param mixed                $client_id   - client identifier related to the access token.
+     * @param mixed                $user_id     - user id associated with the access token
+     * @param string               $scope       - scopes to be stored in space-separated string.
+     * @return array
+     */
+    public function createAccessToken(AccessTokenInterface $accessToken, $client_id, $user_id, $scope)
+    {
+        $includeRefreshToken = false;
+
+        return $accessToken->createAccessToken($client_id, $user_id, $scope, $includeRefreshToken);
+    }
+}

+ 62 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/OpenID/Controller/UserInfoController.php

@@ -0,0 +1,62 @@
+<?php
+
+namespace OAuth2\OpenID\Controller;
+
+use OAuth2\Scope;
+use OAuth2\TokenType\TokenTypeInterface;
+use OAuth2\Storage\AccessTokenInterface;
+use OAuth2\OpenID\Storage\UserClaimsInterface;
+use OAuth2\Controller\ResourceController;
+use OAuth2\ScopeInterface;
+use OAuth2\RequestInterface;
+use OAuth2\ResponseInterface;
+
+/**
+ * @see OAuth2\Controller\UserInfoControllerInterface
+ */
+class UserInfoController extends ResourceController implements UserInfoControllerInterface
+{
+    /**
+     * @var UserClaimsInterface
+     */
+    protected $userClaimsStorage;
+
+    /**
+     * Constructor
+     *
+     * @param TokenTypeInterface   $tokenType
+     * @param AccessTokenInterface $tokenStorage
+     * @param UserClaimsInterface  $userClaimsStorage
+     * @param array                $config
+     * @param ScopeInterface       $scopeUtil
+     */
+    public function __construct(TokenTypeInterface $tokenType, AccessTokenInterface $tokenStorage, UserClaimsInterface $userClaimsStorage, $config = array(), ?ScopeInterface $scopeUtil = null)
+    {
+        parent::__construct($tokenType, $tokenStorage, $config, $scopeUtil);
+
+        $this->userClaimsStorage = $userClaimsStorage;
+    }
+
+    /**
+     * Handle the user info request
+     *
+     * @param RequestInterface $request
+     * @param ResponseInterface $response
+     * @return void
+     */
+    public function handleUserInfoRequest(RequestInterface $request, ResponseInterface $response)
+    {
+        if (!$this->verifyResourceRequest($request, $response, 'openid')) {
+            return;
+        }
+
+        $token = $this->getToken();
+        $claims = $this->userClaimsStorage->getUserClaims($token['user_id'], $token['scope']);
+        // The sub Claim MUST always be returned in the UserInfo Response.
+        // http://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
+        $claims += array(
+            'sub' => $token['user_id'],
+        );
+        $response->addParameters($claims);
+    }
+}

+ 178 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/OpenID/ResponseType/IdToken.php

@@ -0,0 +1,178 @@
+<?php
+
+namespace OAuth2\OpenID\ResponseType;
+
+use OAuth2\Encryption\EncryptionInterface;
+use OAuth2\Encryption\Jwt;
+use OAuth2\Storage\PublicKeyInterface;
+use OAuth2\OpenID\Storage\UserClaimsInterface;
+use LogicException;
+
+class IdToken implements IdTokenInterface
+{
+    /**
+     * @var UserClaimsInterface
+     */
+    protected $userClaimsStorage;
+    /**
+     * @var PublicKeyInterface
+     */
+    protected $publicKeyStorage;
+
+    /**
+     * @var array
+     */
+    protected $config;
+
+    /**
+     * @var EncryptionInterface
+     */
+    protected $encryptionUtil;
+
+    /**
+     * Constructor
+     *
+     * @param UserClaimsInterface $userClaimsStorage
+     * @param PublicKeyInterface $publicKeyStorage
+     * @param array $config
+     * @param EncryptionInterface $encryptionUtil
+     * @throws LogicException
+     */
+    public function __construct(UserClaimsInterface $userClaimsStorage, PublicKeyInterface $publicKeyStorage, array $config = array(), ?EncryptionInterface $encryptionUtil = null)
+    {
+        $this->userClaimsStorage = $userClaimsStorage;
+        $this->publicKeyStorage = $publicKeyStorage;
+        if (is_null($encryptionUtil)) {
+            $encryptionUtil = new Jwt();
+        }
+        $this->encryptionUtil = $encryptionUtil;
+
+        if (!isset($config['issuer'])) {
+            throw new LogicException('config parameter "issuer" must be set');
+        }
+        $this->config = array_merge(array(
+            'id_lifetime' => 3600,
+        ), $config);
+    }
+
+    /**
+     * @param array $params
+     * @param null $userInfo
+     * @return array|mixed
+     */
+    public function getAuthorizeResponse($params, $userInfo = null)
+    {
+        // build the URL to redirect to
+        $result = array('query' => array());
+        $params += array('scope' => null, 'state' => null, 'nonce' => null);
+
+        // create the id token.
+        list($user_id, $auth_time) = $this->getUserIdAndAuthTime($userInfo);
+        $userClaims = $this->userClaimsStorage->getUserClaims($user_id, $params['scope']);
+
+        $id_token = $this->createIdToken($params['client_id'], $userInfo, $params['nonce'], $userClaims, null);
+        $result["fragment"] = array('id_token' => $id_token);
+        if (isset($params['state'])) {
+            $result["fragment"]["state"] = $params['state'];
+        }
+
+        return array($params['redirect_uri'], $result);
+    }
+
+    /**
+     * Create id token
+     *
+     * @param string $client_id
+     * @param mixed  $userInfo
+     * @param mixed  $nonce
+     * @param mixed  $userClaims
+     * @param mixed  $access_token
+     * @return mixed|string
+     */
+    public function createIdToken($client_id, $userInfo, $nonce = null, $userClaims = null, $access_token = null)
+    {
+        // pull auth_time from user info if supplied
+        list($user_id, $auth_time) = $this->getUserIdAndAuthTime($userInfo);
+
+        $token = array(
+            'iss'        => $this->config['issuer'],
+            'sub'        => $user_id,
+            'aud'        => $client_id,
+            'iat'        => time(),
+            'exp'        => time() + $this->config['id_lifetime'],
+            'auth_time'  => $auth_time,
+        );
+
+        if ($nonce) {
+            $token['nonce'] = $nonce;
+        }
+
+        if ($userClaims) {
+            $token += $userClaims;
+        }
+
+        if ($access_token) {
+            $token['at_hash'] = $this->createAtHash($access_token, $client_id);
+        }
+
+        return $this->encodeToken($token, $client_id);
+    }
+
+    /**
+     * @param $access_token
+     * @param null $client_id
+     * @return mixed|string
+     */
+    protected function createAtHash($access_token, $client_id = null)
+    {
+        // maps HS256 and RS256 to sha256, etc.
+        $algorithm = $this->publicKeyStorage->getEncryptionAlgorithm($client_id);
+        $hash_algorithm = 'sha' . substr($algorithm, 2);
+        $hash = hash($hash_algorithm, $access_token, true);
+        $at_hash = substr($hash, 0, strlen($hash) / 2);
+
+        return $this->encryptionUtil->urlSafeB64Encode($at_hash);
+    }
+
+    /**
+     * @param array $token
+     * @param null $client_id
+     * @return mixed|string
+     */
+    protected function encodeToken(array $token, $client_id = null)
+    {
+        $private_key = $this->publicKeyStorage->getPrivateKey($client_id);
+        $algorithm = $this->publicKeyStorage->getEncryptionAlgorithm($client_id);
+
+        return $this->encryptionUtil->encode($token, $private_key, $algorithm);
+    }
+
+    /**
+     * @param $userInfo
+     * @return array
+     * @throws LogicException
+     */
+    private function getUserIdAndAuthTime($userInfo)
+    {
+        $auth_time = null;
+
+        // support an array for user_id / auth_time
+        if (is_array($userInfo)) {
+            if (!isset($userInfo['user_id'])) {
+                throw new LogicException('if $user_id argument is an array, user_id index must be set');
+            }
+
+            $auth_time = isset($userInfo['auth_time']) ? $userInfo['auth_time'] : null;
+            $user_id = $userInfo['user_id'];
+        } else {
+            $user_id = $userInfo;
+        }
+
+        if (is_null($auth_time)) {
+            $auth_time = time();
+        }
+
+        // userInfo is a scalar, and so this is the $user_id. Auth Time is null
+        return array($user_id, $auth_time);
+    }
+}

+ 252 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/Request.php

@@ -0,0 +1,252 @@
+<?php
+
+namespace OAuth2;
+
+use LogicException;
+
+/**
+ * OAuth2\Request
+ * This class is taken from the Symfony2 Framework and is part of the Symfony package.
+ * See Symfony\Component\HttpFoundation\Request (https://github.com/symfony/symfony)
+ */
+class Request implements RequestInterface
+{
+    public $attributes;
+    public $request;
+    public $query;
+    public $server;
+    public $files;
+    public $cookies;
+    public $headers;
+    public $content;
+
+    /**
+     * Constructor.
+     *
+     * @param array  $query      - The GET parameters
+     * @param array  $request    - The POST parameters
+     * @param array  $attributes - The request attributes (parameters parsed from the PATH_INFO, ...)
+     * @param array  $cookies    - The COOKIE parameters
+     * @param array  $files      - The FILES parameters
+     * @param array  $server     - The SERVER parameters
+     * @param string $content    - The raw body data
+     * @param array  $headers    - The headers
+     *
+     * @api
+     */
+    public function __construct(array $query = array(), array $request = array(), array $attributes = array(), array $cookies = array(), array $files = array(), array $server = array(), $content = null, ?array $headers = null)
+    {
+        $this->initialize($query, $request, $attributes, $cookies, $files, $server, $content, $headers);
+    }
+
+    /**
+     * Sets the parameters for this request.
+     *
+     * This method also re-initializes all properties.
+     *
+     * @param array  $query      - The GET parameters
+     * @param array  $request    - The POST parameters
+     * @param array  $attributes - The request attributes (parameters parsed from the PATH_INFO, ...)
+     * @param array  $cookies    - The COOKIE parameters
+     * @param array  $files      - The FILES parameters
+     * @param array  $server     - The SERVER parameters
+     * @param string $content    - The raw body data
+     * @param array  $headers    - The headers
+     *
+     * @api
+     */
+    public function initialize(array $query = array(), array $request = array(), array $attributes = array(), array $cookies = array(), array $files = array(), array $server = array(), $content = null, ?array $headers = null)
+    {
+        $this->request = $request;
+        $this->query = $query;
+        $this->attributes = $attributes;
+        $this->cookies = $cookies;
+        $this->files = $files;
+        $this->server = $server;
+        $this->content = $content;
+
+        if ($headers === null) {
+            $headers = array();
+        }
+
+        $this->headers = $headers + $this->getHeadersFromServer($this->server);
+    }
+
+    /**
+     * @param string $name
+     * @param mixed  $default
+     * @return mixed
+     */
+    public function query($name, $default = null)
+    {
+        return isset($this->query[$name]) ? $this->query[$name] : $default;
+    }
+
+    /**
+     * @param string $name
+     * @param mixed  $default
+     * @return mixed
+     */
+    public function request($name, $default = null)
+    {
+        return isset($this->request[$name]) ? $this->request[$name] : $default;
+    }
+
+    /**
+     * @param string $name
+     * @param mixed  $default
+     * @return mixed
+     */
+    public function server($name, $default = null)
+    {
+        return isset($this->server[$name]) ? $this->server[$name] : $default;
+    }
+
+    /**
+     * @param string $name
+     * @param mixed  $default
+     * @return mixed
+     */
+    public function headers($name, $default = null)
+    {
+        $headers = array_change_key_case($this->headers);
+        $name = strtolower($name);
+
+        return isset($headers[$name]) ? $headers[$name] : $default;
+    }
+
+    /**
+     * @return array
+     */
+    public function getAllQueryParameters()
+    {
+        return $this->query;
+    }
+
+    /**
+     * Returns the request body content.
+     *
+     * @param boolean $asResource - If true, a resource will be returned
+     * @return string|resource    - The request body content or a resource to read the body stream.
+     *
+     * @throws LogicException
+     */
+    public function getContent($asResource = false)
+    {
+        if (false === $this->content || (true === $asResource && null !== $this->content)) {
+            throw new LogicException('getContent() can only be called once when using the resource return type.');
+        }
+
+        if (true === $asResource) {
+            $this->content = false;
+
+            return fopen('php://input', 'rb');
+        }
+
+        if (null === $this->content) {
+            $this->content = file_get_contents('php://input');
+        }
+
+        return $this->content;
+    }
+
+    /**
+     * @param array $server
+     * @return array
+     */
+    private function getHeadersFromServer($server)
+    {
+        $headers = array();
+        foreach ($server as $key => $value) {
+            if (0 === strpos($key, 'HTTP_')) {
+                $headers[substr($key, 5)] = $value;
+            }
+            // CONTENT_* are not prefixed with HTTP_
+            elseif (in_array($key, array('CONTENT_LENGTH', 'CONTENT_MD5', 'CONTENT_TYPE'))) {
+                $headers[$key] = $value;
+            }
+        }
+
+        if (isset($server['PHP_AUTH_USER'])) {
+            $headers['PHP_AUTH_USER'] = $server['PHP_AUTH_USER'];
+            $headers['PHP_AUTH_PW'] = isset($server['PHP_AUTH_PW']) ? $server['PHP_AUTH_PW'] : '';
+        } else {
+            /*
+             * php-cgi under Apache does not pass HTTP Basic user/pass to PHP by default
+             * For this workaround to work, add this line to your .htaccess file:
+             * RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
+             *
+             * A sample .htaccess file:
+             * RewriteEngine On
+             * RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
+             * RewriteCond %{REQUEST_FILENAME} !-f
+             * RewriteRule ^(.*)$ app.php [QSA,L]
+             */
+
+            $authorizationHeader = null;
+            if (isset($server['HTTP_AUTHORIZATION'])) {
+                $authorizationHeader = $server['HTTP_AUTHORIZATION'];
+            } elseif (isset($server['REDIRECT_HTTP_AUTHORIZATION'])) {
+                $authorizationHeader = $server['REDIRECT_HTTP_AUTHORIZATION'];
+            } elseif (function_exists('apache_request_headers')) {
+                $requestHeaders = (array) apache_request_headers();
+
+                // Server-side fix for bug in old Android versions (a nice side-effect of this fix means we don't care about capitalization for Authorization)
+                $requestHeaders = array_combine(array_map('ucwords', array_keys($requestHeaders)), array_values($requestHeaders));
+
+                if (isset($requestHeaders['Authorization'])) {
+                    $authorizationHeader = trim($requestHeaders['Authorization']);
+                }
+            }
+
+            if (null !== $authorizationHeader) {
+                $headers['AUTHORIZATION'] = $authorizationHeader;
+                // Decode AUTHORIZATION header into PHP_AUTH_USER and PHP_AUTH_PW when authorization header is basic
+                if (0 === stripos($authorizationHeader, 'basic')) {
+                    $exploded = explode(':', base64_decode(substr($authorizationHeader, 6)));
+                    if (count($exploded) == 2) {
+                        list($headers['PHP_AUTH_USER'], $headers['PHP_AUTH_PW']) = $exploded;
+                    }
+                }
+            }
+        }
+
+        // PHP_AUTH_USER/PHP_AUTH_PW
+        if (isset($headers['PHP_AUTH_USER'])) {
+            $headers['AUTHORIZATION'] = 'Basic '.base64_encode($headers['PHP_AUTH_USER'].':'.$headers['PHP_AUTH_PW']);
+        }
+
+        return $headers;
+    }
+
+    /**
+     * Creates a new request with values from PHP's super globals.
+     *
+     * @return Request - A new request
+     *
+     * @api
+     */
+    public static function createFromGlobals()
+    {
+        $class = get_called_class();
+
+        /** @var Request $request */
+        $request = new $class($_GET, $_POST, array(), $_COOKIE, $_FILES, $_SERVER);
+
+        $contentType = $request->server('CONTENT_TYPE', '');
+        $requestMethod = $request->server('REQUEST_METHOD', 'GET');
+        if (0 === strpos($contentType, 'application/x-www-form-urlencoded')
+            && in_array(strtoupper($requestMethod), array('PUT', 'DELETE'))
+        ) {
+            parse_str($request->getContent(), $data);
+            $request->request = $data;
+        } elseif (0 === strpos($contentType, 'application/json')
+            && in_array(strtoupper($requestMethod), array('POST', 'PUT', 'DELETE'))
+        ) {
+            $data = json_decode($request->getContent(), true);
+            $request->request = $data;
+        }
+
+        return $request;
+    }
+}

+ 218 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/ResponseType/AccessToken.php

@@ -0,0 +1,218 @@
+<?php
+
+namespace OAuth2\ResponseType;
+
+use OAuth2\Storage\AccessTokenInterface as AccessTokenStorageInterface;
+use OAuth2\Storage\RefreshTokenInterface;
+use RuntimeException;
+
+/**
+ * @author Brent Shaffer <bshafs at gmail dot com>
+ */
+class AccessToken implements AccessTokenInterface
+{
+    /**
+     * @var AccessTokenInterface
+     */
+    protected $tokenStorage;
+
+    /**
+     * @var RefreshTokenInterface
+     */
+    protected $refreshStorage;
+
+    /**
+     * @var array
+     */
+    protected $config;
+
+    /**
+     * @param AccessTokenStorageInterface $tokenStorage   - REQUIRED Storage class for saving access token information
+     * @param RefreshTokenInterface       $refreshStorage - OPTIONAL Storage class for saving refresh token information
+     * @param array                       $config         - OPTIONAL Configuration options for the server
+     * @code
+     *     $config = array(
+     *         'token_type' => 'bearer',            // token type identifier
+     *         'access_lifetime' => 3600,           // time before access token expires
+     *         'refresh_token_lifetime' => 1209600, // time before refresh token expires
+     *     );
+     * @endcode
+     */
+    public function __construct(AccessTokenStorageInterface $tokenStorage, ?RefreshTokenInterface $refreshStorage = null, array $config = array())
+    {
+        $this->tokenStorage = $tokenStorage;
+        $this->refreshStorage = $refreshStorage;
+
+        $this->config = array_merge(array(
+            'token_type'             => 'bearer',
+            'access_lifetime'        => 3600,
+            'refresh_token_lifetime' => 1209600,
+        ), $config);
+    }
+
+    /**
+     * Get authorize response
+     *
+     * @param array $params
+     * @param mixed $user_id
+     * @return array
+     */
+    public function getAuthorizeResponse($params, $user_id = null)
+    {
+        // build the URL to redirect to
+        $result = array('query' => array());
+
+        $params += array('scope' => null, 'state' => null);
+
+        /*
+         * a refresh token MUST NOT be included in the fragment
+         *
+         * @see http://tools.ietf.org/html/rfc6749#section-4.2.2
+         */
+        $includeRefreshToken = false;
+        $result["fragment"] = $this->createAccessToken($params['client_id'], $user_id, $params['scope'], $includeRefreshToken);
+
+        if (isset($params['state'])) {
+            $result["fragment"]["state"] = $params['state'];
+        }
+
+        return array($params['redirect_uri'], $result);
+    }
+
+    /**
+     * Handle the creation of access token, also issue refresh token if supported / desirable.
+     *
+     * @param mixed  $client_id           - client identifier related to the access token.
+     * @param mixed  $user_id             - user ID associated with the access token
+     * @param string $scope               - OPTIONAL scopes to be stored in space-separated string.
+     * @param bool   $includeRefreshToken - if true, a new refresh_token will be added to the response
+     * @return array
+     *
+     * @see http://tools.ietf.org/html/rfc6749#section-5
+     * @ingroup oauth2_section_5
+     */
+    public function createAccessToken($client_id, $user_id, $scope = null, $includeRefreshToken = true)
+    {
+        $token = array(
+            "access_token" => $this->generateAccessToken(),
+            "expires_in" => $this->config['access_lifetime'],
+            "token_type" => $this->config['token_type'],
+            "scope" => $scope
+        );
+
+        $this->tokenStorage->setAccessToken($token["access_token"], $client_id, $user_id, $this->config['access_lifetime'] ? time() + $this->config['access_lifetime'] : null, $scope);
+
+        /*
+         * Issue a refresh token also, if we support them
+         *
+         * Refresh Tokens are considered supported if an instance of OAuth2\Storage\RefreshTokenInterface
+         * is supplied in the constructor
+         */
+        if ($includeRefreshToken && $this->refreshStorage) {
+            $token["refresh_token"] = $this->generateRefreshToken();
+            $expires = 0;
+            if ($this->config['refresh_token_lifetime'] > 0) {
+                $expires = time() + $this->config['refresh_token_lifetime'];
+            }
+            $this->refreshStorage->setRefreshToken($token['refresh_token'], $client_id, $user_id, $expires, $scope);
+        }
+
+        return $token;
+    }
+
+    /**
+     * Generates an unique access token.
+     *
+     * Implementing classes may want to override this function to implement
+     * other access token generation schemes.
+     *
+     * @return string - A unique access token.
+     *
+     * @ingroup oauth2_section_4
+     */
+    protected function generateAccessToken()
+    {
+        if (function_exists('random_bytes')) {
+            $randomData = random_bytes(20);
+            if ($randomData !== false && strlen($randomData) === 20) {
+                return bin2hex($randomData);
+            }
+        }
+        if (function_exists('openssl_random_pseudo_bytes')) {
+            $randomData = openssl_random_pseudo_bytes(20);
+            if ($randomData !== false && strlen($randomData) === 20) {
+                return bin2hex($randomData);
+            }
+        }
+        if (function_exists('mcrypt_create_iv')) {
+            $randomData = mcrypt_create_iv(20, MCRYPT_DEV_URANDOM);
+            if ($randomData !== false && strlen($randomData) === 20) {
+                return bin2hex($randomData);
+            }
+        }
+        if (@file_exists('/dev/urandom')) { // Get 100 bytes of random data
+            $randomData = file_get_contents('/dev/urandom', false, null, 0, 20);
+            if ($randomData !== false && strlen($randomData) === 20) {
+                return bin2hex($randomData);
+            }
+        }
+        // Last resort which you probably should just get rid of:
+        $randomData = mt_rand() . mt_rand() . mt_rand() . mt_rand() . microtime(true) . uniqid(mt_rand(), true);
+
+        return substr(hash('sha512', $randomData), 0, 40);
+    }
+
+    /**
+     * Generates an unique refresh token
+     *
+     * Implementing classes may want to override this function to implement
+     * other refresh token generation schemes.
+     *
+     * @return string - A unique refresh token.
+     *
+     * @ingroup oauth2_section_4
+     * @see OAuth2::generateAccessToken()
+     */
+    protected function generateRefreshToken()
+    {
+        return $this->generateAccessToken(); // let's reuse the same scheme for token generation
+    }
+
+    /**
+     * Handle the revoking of refresh tokens, and access tokens if supported / desirable
+     * RFC7009 specifies that "If the server is unable to locate the token using
+     * the given hint, it MUST extend its search across all of its supported token types"
+     *
+     * @param $token
+     * @param null $tokenTypeHint
+     * @throws RuntimeException
+     * @return boolean
+     */
+    public function revokeToken($token, $tokenTypeHint = null)
+    {
+        if ($tokenTypeHint == 'refresh_token') {
+            if ($this->refreshStorage && $revoked = $this->refreshStorage->unsetRefreshToken($token)) {
+                return true;
+            }
+        }
+
+        /** @TODO remove in v2 */
+        if (!method_exists($this->tokenStorage, 'unsetAccessToken')) {
+            throw new RuntimeException(
+                sprintf('Token storage %s must implement unsetAccessToken method', get_class($this->tokenStorage)
+            ));
+        }
+
+        $revoked = $this->tokenStorage->unsetAccessToken($token);
+
+        // if a typehint is supplied and fails, try other storages 
+        // @see https://tools.ietf.org/html/rfc7009#section-2.1
+        if (!$revoked && $tokenTypeHint != 'refresh_token') {
+            if ($this->refreshStorage) {
+                $revoked = $this->refreshStorage->unsetRefreshToken($token);
+            }
+        }
+
+        return $revoked;
+    }
+}

+ 159 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/ResponseType/JwtAccessToken.php

@@ -0,0 +1,159 @@
+<?php
+
+namespace OAuth2\ResponseType;
+
+use OAuth2\Encryption\EncryptionInterface;
+use OAuth2\Encryption\Jwt;
+use OAuth2\Storage\AccessTokenInterface as AccessTokenStorageInterface;
+use OAuth2\Storage\RefreshTokenInterface;
+use OAuth2\Storage\PublicKeyInterface;
+use OAuth2\Storage\Memory;
+
+/**
+ * @author Brent Shaffer <bshafs at gmail dot com>
+ */
+class JwtAccessToken extends AccessToken
+{
+    protected $publicKeyStorage;
+    protected $encryptionUtil;
+
+    /**
+     * @param PublicKeyInterface          $publicKeyStorage -
+     * @param AccessTokenStorageInterface $tokenStorage     -
+     * @param RefreshTokenInterface       $refreshStorage   -
+     * @param array                       $config           - array with key store_encrypted_token_string (bool true)
+     *                                                        whether the entire encrypted string is stored,
+     *                                                        or just the token ID is stored
+     * @param EncryptionInterface         $encryptionUtil   -
+     */
+    public function __construct(?PublicKeyInterface $publicKeyStorage = null, ?AccessTokenStorageInterface $tokenStorage = null, ?RefreshTokenInterface $refreshStorage = null, array $config = array(), ?EncryptionInterface $encryptionUtil = null)
+    {
+        $this->publicKeyStorage = $publicKeyStorage;
+        $config = array_merge(array(
+            'store_encrypted_token_string' => true,
+            'issuer' => ''
+        ), $config);
+        if (is_null($tokenStorage)) {
+            // a pass-thru, so we can call the parent constructor
+            $tokenStorage = new Memory();
+        }
+        if (is_null($encryptionUtil)) {
+            $encryptionUtil = new Jwt();
+        }
+        $this->encryptionUtil = $encryptionUtil;
+        parent::__construct($tokenStorage, $refreshStorage, $config);
+    }
+
+    /**
+     * Handle the creation of access token, also issue refresh token if supported / desirable.
+     *
+     * @param mixed  $client_id           - Client identifier related to the access token.
+     * @param mixed  $user_id             - User ID associated with the access token
+     * @param string $scope               - (optional) Scopes to be stored in space-separated string.
+     * @param bool   $includeRefreshToken - If true, a new refresh_token will be added to the response
+     * @return array                      - The access token
+     *
+     * @see http://tools.ietf.org/html/rfc6749#section-5
+     * @ingroup oauth2_section_5
+     */
+    public function createAccessToken($client_id, $user_id, $scope = null, $includeRefreshToken = true)
+    {
+        // payload to encrypt
+        $payload = $this->createPayload($client_id, $user_id, $scope);
+
+        /*
+         * Encode the payload data into a single JWT access_token string
+         */
+        $access_token = $this->encodeToken($payload, $client_id);
+
+        /*
+         * Save the token to a secondary storage.  This is implemented on the
+         * OAuth2\Storage\JwtAccessToken side, and will not actually store anything,
+         * if no secondary storage has been supplied
+         */
+        $token_to_store = $this->config['store_encrypted_token_string'] ? $access_token : $payload['id'];
+        $this->tokenStorage->setAccessToken($token_to_store, $client_id, $user_id, $this->config['access_lifetime'] ? time() + $this->config['access_lifetime'] : null, $scope);
+
+        // token to return to the client
+        $token = array(
+            'access_token' => $access_token,
+            'expires_in' => $this->config['access_lifetime'],
+            'token_type' => $this->config['token_type'],
+            'scope' => $scope
+        );
+
+        /*
+         * Issue a refresh token also, if we support them
+         *
+         * Refresh Tokens are considered supported if an instance of OAuth2\Storage\RefreshTokenInterface
+         * is supplied in the constructor
+         */
+        if ($includeRefreshToken && $this->refreshStorage) {
+            $refresh_token = $this->generateRefreshToken();
+            $expires = 0;
+            if ($this->config['refresh_token_lifetime'] > 0) {
+                $expires = time() + $this->config['refresh_token_lifetime'];
+            }
+            $this->refreshStorage->setRefreshToken($refresh_token, $client_id, $user_id, $expires, $scope);
+            $token['refresh_token'] = $refresh_token;
+        }
+
+        return $token;
+    }
+
+    /**
+     * @param array $token
+     * @param mixed $client_id
+     * @return mixed
+     */
+    protected function encodeToken(array $token, $client_id = null)
+    {
+        $private_key = $this->publicKeyStorage->getPrivateKey($client_id);
+        $algorithm   = $this->publicKeyStorage->getEncryptionAlgorithm($client_id);
+
+        return $this->encryptionUtil->encode($token, $private_key, $algorithm);
+    }
+
+    /**
+     * This function can be used to create custom JWT payloads
+     *
+     * @param mixed  $client_id           - Client identifier related to the access token.
+     * @param mixed  $user_id             - User ID associated with the access token
+     * @param string $scope               - (optional) Scopes to be stored in space-separated string.
+     * @return array                      - The access token
+     */
+    protected function createPayload($client_id, $user_id, $scope = null)
+    {
+        // token to encrypt
+        $expires = time() + $this->config['access_lifetime'];
+        $id = $this->generateAccessToken();
+
+        $payload = array(
+            'id'         => $id, // for BC (see #591)
+            'jti'        => $id,
+            'iss'        => $this->config['issuer'],
+            'aud'        => $client_id,
+            'sub'        => $user_id,
+            'exp'        => $expires,
+            'iat'        => time(),
+            'token_type' => $this->config['token_type'],
+            'scope'      => $scope
+        );
+        
+        if (isset($this->config['jwt_extra_payload_callable'])) {
+            if (!is_callable($this->config['jwt_extra_payload_callable'])) {
+                throw new \InvalidArgumentException('jwt_extra_payload_callable is not callable');
+            }
+            
+            $extra = call_user_func($this->config['jwt_extra_payload_callable'], $client_id, $user_id, $scope);
+            
+            if (!is_array($extra)) {
+                throw new \InvalidArgumentException('jwt_extra_payload_callable must return array');
+            }
+            
+            $payload = array_merge($extra, $payload);
+        }
+        
+        return $payload;
+    }
+}

+ 1020 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/Server.php

@@ -0,0 +1,1020 @@
+<?php
+
+namespace OAuth2;
+
+use OAuth2\Controller\ResourceControllerInterface;
+use OAuth2\Controller\ResourceController;
+use OAuth2\OpenID\Controller\UserInfoControllerInterface;
+use OAuth2\OpenID\Controller\UserInfoController;
+use OAuth2\OpenID\Controller\AuthorizeController as OpenIDAuthorizeController;
+use OAuth2\OpenID\ResponseType\AuthorizationCode as OpenIDAuthorizationCodeResponseType;
+use OAuth2\OpenID\Storage\AuthorizationCodeInterface as OpenIDAuthorizationCodeInterface;
+use OAuth2\OpenID\GrantType\AuthorizationCode as OpenIDAuthorizationCodeGrantType;
+use OAuth2\Controller\AuthorizeControllerInterface;
+use OAuth2\Controller\AuthorizeController;
+use OAuth2\Controller\TokenControllerInterface;
+use OAuth2\Controller\TokenController;
+use OAuth2\ClientAssertionType\ClientAssertionTypeInterface;
+use OAuth2\ClientAssertionType\HttpBasic;
+use OAuth2\ResponseType\ResponseTypeInterface;
+use OAuth2\ResponseType\AuthorizationCode as AuthorizationCodeResponseType;
+use OAuth2\ResponseType\AccessToken;
+use OAuth2\ResponseType\JwtAccessToken;
+use OAuth2\OpenID\ResponseType\CodeIdToken;
+use OAuth2\OpenID\ResponseType\IdToken;
+use OAuth2\OpenID\ResponseType\IdTokenToken;
+use OAuth2\TokenType\TokenTypeInterface;
+use OAuth2\TokenType\Bearer;
+use OAuth2\GrantType\GrantTypeInterface;
+use OAuth2\GrantType\UserCredentials;
+use OAuth2\GrantType\ClientCredentials;
+use OAuth2\GrantType\RefreshToken;
+use OAuth2\GrantType\AuthorizationCode;
+use OAuth2\Storage\ClientCredentialsInterface;
+use OAuth2\Storage\ClientInterface;
+use OAuth2\Storage\JwtAccessToken as JwtAccessTokenStorage;
+use OAuth2\Storage\JwtAccessTokenInterface;
+use InvalidArgumentException;
+use LogicException;
+
+/**
+* Server class for OAuth2
+* This class serves as a convience class which wraps the other Controller classes
+*
+* @see \OAuth2\Controller\ResourceController
+* @see \OAuth2\Controller\AuthorizeController
+* @see \OAuth2\Controller\TokenController
+*/
+class Server implements ResourceControllerInterface,
+    AuthorizeControllerInterface,
+    TokenControllerInterface,
+    UserInfoControllerInterface
+{
+    /**
+     * @var ResponseInterface
+     */
+    protected $response;
+
+    /**
+     * @var array
+     */
+    protected $config;
+
+    /**
+     * @var array
+     */
+    protected $storages;
+
+    /**
+     * @var AuthorizeControllerInterface
+     */
+    protected $authorizeController;
+
+    /**
+     * @var TokenControllerInterface
+     */
+    protected $tokenController;
+
+    /**
+     * @var ResourceControllerInterface
+     */
+    protected $resourceController;
+
+    /**
+     * @var UserInfoControllerInterface
+     */
+    protected $userInfoController;
+
+    /**
+     * @var array
+     */
+    protected $grantTypes = array();
+
+    /**
+     * @var array
+     */
+    protected $responseTypes = array();
+
+    /**
+     * @var TokenTypeInterface
+     */
+    protected $tokenType;
+
+    /**
+     * @var ScopeInterface
+     */
+    protected $scopeUtil;
+
+    /**
+     * @var ClientAssertionTypeInterface
+     */
+    protected $clientAssertionType;
+
+    /**
+     * @var array
+     */
+    protected $storageMap = array(
+        'access_token' => 'OAuth2\Storage\AccessTokenInterface',
+        'authorization_code' => 'OAuth2\Storage\AuthorizationCodeInterface',
+        'client_credentials' => 'OAuth2\Storage\ClientCredentialsInterface',
+        'client' => 'OAuth2\Storage\ClientInterface',
+        'refresh_token' => 'OAuth2\Storage\RefreshTokenInterface',
+        'user_credentials' => 'OAuth2\Storage\UserCredentialsInterface',
+        'user_claims' => 'OAuth2\OpenID\Storage\UserClaimsInterface',
+        'public_key' => 'OAuth2\Storage\PublicKeyInterface',
+        'jwt_bearer' => 'OAuth2\Storage\JWTBearerInterface',
+        'scope' => 'OAuth2\Storage\ScopeInterface',
+    );
+
+    /**
+     * @var array
+     */
+    protected $responseTypeMap = array(
+        'token' => 'OAuth2\ResponseType\AccessTokenInterface',
+        'code' => 'OAuth2\ResponseType\AuthorizationCodeInterface',
+        'id_token' => 'OAuth2\OpenID\ResponseType\IdTokenInterface',
+        'id_token token' => 'OAuth2\OpenID\ResponseType\IdTokenTokenInterface',
+        'code id_token' => 'OAuth2\OpenID\ResponseType\CodeIdTokenInterface',
+    );
+
+    /**
+     * @param mixed                        $storage             (array or OAuth2\Storage) - single object or array of objects implementing the
+     *                                                          required storage types (ClientCredentialsInterface and AccessTokenInterface as a minimum)
+     * @param array                        $config              specify a different token lifetime, token header name, etc
+     * @param array                        $grantTypes          An array of OAuth2\GrantType\GrantTypeInterface to use for granting access tokens
+     * @param array                        $responseTypes       Response types to use. array keys should be "code" and "token" for
+     *                                                          Access Token and Authorization Code response types
+     * @param TokenTypeInterface           $tokenType           The token type object to use. Valid token types are "bearer" and "mac"
+     * @param ScopeInterface               $scopeUtil           The scope utility class to use to validate scope
+     * @param ClientAssertionTypeInterface $clientAssertionType The method in which to verify the client identity.  Default is HttpBasic
+     *
+     * @ingroup oauth2_section_7
+     */
+    public function __construct($storage = array(), array $config = array(), array $grantTypes = array(), array $responseTypes = array(), ?TokenTypeInterface $tokenType = null, ?ScopeInterface $scopeUtil = null, ?ClientAssertionTypeInterface $clientAssertionType = null)
+    {
+        $storage = is_array($storage) ? $storage : array($storage);
+        $this->storages = array();
+        foreach ($storage as $key => $service) {
+            $this->addStorage($service, $key);
+        }
+
+        // merge all config values.  These get passed to our controller objects
+        $this->config = array_merge(array(
+            'use_jwt_access_tokens'        => false,
+            'jwt_extra_payload_callable' => null,
+            'store_encrypted_token_string' => true,
+            'use_openid_connect'       => false,
+            'id_lifetime'              => 3600,
+            'access_lifetime'          => 3600,
+            'www_realm'                => 'Service',
+            'token_param_name'         => 'access_token',
+            'token_bearer_header_name' => 'Bearer',
+            'enforce_state'            => true,
+            'require_exact_redirect_uri' => true,
+            'allow_implicit'           => false,
+            'enforce_pkce'             => false,
+            'allow_credentials_in_request_body' => true,
+            'allow_public_clients'     => true,
+            'always_issue_new_refresh_token' => false,
+            'unset_refresh_token_after_use' => true,
+        ), $config);
+
+        foreach ($grantTypes as $key => $grantType) {
+            $this->addGrantType($grantType, $key);
+        }
+
+        foreach ($responseTypes as $key => $responseType) {
+            $this->addResponseType($responseType, $key);
+        }
+
+        $this->tokenType = $tokenType;
+        $this->scopeUtil = $scopeUtil;
+        $this->clientAssertionType = $clientAssertionType;
+
+        if ($this->config['use_openid_connect']) {
+            $this->validateOpenIdConnect();
+        }
+    }
+
+    /**
+     * @return AuthorizeControllerInterface
+     */
+    public function getAuthorizeController()
+    {
+        if (is_null($this->authorizeController)) {
+            $this->authorizeController = $this->createDefaultAuthorizeController();
+        }
+
+        return $this->authorizeController;
+    }
+
+    /**
+     * @return TokenController
+     */
+    public function getTokenController()
+    {
+        if (is_null($this->tokenController)) {
+            $this->tokenController = $this->createDefaultTokenController();
+        }
+
+        return $this->tokenController;
+    }
+
+    /**
+     * @return ResourceControllerInterface
+     */
+    public function getResourceController()
+    {
+        if (is_null($this->resourceController)) {
+            $this->resourceController = $this->createDefaultResourceController();
+        }
+
+        return $this->resourceController;
+    }
+
+    /**
+     * @return UserInfoControllerInterface
+     */
+    public function getUserInfoController()
+    {
+        if (is_null($this->userInfoController)) {
+            $this->userInfoController = $this->createDefaultUserInfoController();
+        }
+
+        return $this->userInfoController;
+    }
+
+    /**
+     * @param AuthorizeControllerInterface $authorizeController
+     */
+    public function setAuthorizeController(AuthorizeControllerInterface $authorizeController)
+    {
+        $this->authorizeController = $authorizeController;
+    }
+
+    /**
+     * @param TokenControllerInterface $tokenController
+     */
+    public function setTokenController(TokenControllerInterface $tokenController)
+    {
+        $this->tokenController = $tokenController;
+    }
+
+    /**
+     * @param ResourceControllerInterface $resourceController
+     */
+    public function setResourceController(ResourceControllerInterface $resourceController)
+    {
+        $this->resourceController = $resourceController;
+    }
+
+    /**
+     * @param UserInfoControllerInterface $userInfoController
+     */
+    public function setUserInfoController(UserInfoControllerInterface $userInfoController)
+    {
+        $this->userInfoController = $userInfoController;
+    }
+
+    /**
+     * Return claims about the authenticated end-user.
+     * This would be called from the "/UserInfo" endpoint as defined in the spec.
+     *
+     * @param RequestInterface  $request  - Request object to grant access token
+     * @param ResponseInterface $response - Response object containing error messages (failure) or user claims (success)
+     * @return ResponseInterface
+     *
+     * @throws \InvalidArgumentException
+     * @throws \LogicException
+     *
+     * @see http://openid.net/specs/openid-connect-core-1_0.html#UserInfo
+     */
+    public function handleUserInfoRequest(RequestInterface $request, ?ResponseInterface $response = null)
+    {
+        $this->response = is_null($response) ? new Response() : $response;
+        $this->getUserInfoController()->handleUserInfoRequest($request, $this->response);
+
+        return $this->response;
+    }
+
+    /**
+     * Grant or deny a requested access token.
+     * This would be called from the "/token" endpoint as defined in the spec.
+     * Obviously, you can call your endpoint whatever you want.
+     *
+     * @param RequestInterface $request   - Request object to grant access token
+     * @param ResponseInterface $response - Response object containing error messages (failure) or access token (success)
+     * @return ResponseInterface
+     *
+     * @throws \InvalidArgumentException
+     * @throws \LogicException
+     *
+     * @see http://tools.ietf.org/html/rfc6749#section-4
+     * @see http://tools.ietf.org/html/rfc6749#section-10.6
+     * @see http://tools.ietf.org/html/rfc6749#section-4.1.3
+     *
+     * @ingroup oauth2_section_4
+     */
+    public function handleTokenRequest(RequestInterface $request, ?ResponseInterface $response = null)
+    {
+        $this->response = is_null($response) ? new Response() : $response;
+        $this->getTokenController()->handleTokenRequest($request, $this->response);
+
+        return $this->response;
+    }
+
+    /**
+     * @param RequestInterface  $request  - Request object to grant access token
+     * @param ResponseInterface $response - Response object
+     * @return mixed
+     */
+    public function grantAccessToken(RequestInterface $request, ?ResponseInterface $response = null)
+    {
+        $this->response = is_null($response) ? new Response() : $response;
+        $value = $this->getTokenController()->grantAccessToken($request, $this->response);
+
+        return $value;
+    }
+
+    /**
+     * Handle a revoke token request
+     * This would be called from the "/revoke" endpoint as defined in the draft Token Revocation spec
+     *
+     * @see https://tools.ietf.org/html/rfc7009#section-2
+     *
+     * @param RequestInterface $request
+     * @param ResponseInterface $response
+     * @return Response|ResponseInterface
+     */
+    public function handleRevokeRequest(RequestInterface $request, ?ResponseInterface $response = null)
+    {
+        $this->response = is_null($response) ? new Response() : $response;
+        $this->getTokenController()->handleRevokeRequest($request, $this->response);
+
+        return $this->response;
+    }
+
+    /**
+     * Redirect the user appropriately after approval.
+     *
+     * After the user has approved or denied the resource request the
+     * authorization server should call this function to redirect the user
+     * appropriately.
+     *
+     * @param RequestInterface  $request - The request should have the follow parameters set in the querystring:
+     * - response_type: The requested response: an access token, an authorization code, or both.
+     * - client_id: The client identifier as described in Section 2.
+     * - redirect_uri: An absolute URI to which the authorization server will redirect the user-agent to when the
+     *   end-user authorization step is completed.
+     * - scope: (optional) The scope of the resource request expressed as a list of space-delimited strings.
+     * - state: (optional) An opaque value used by the client to maintain state between the request and callback.
+     *
+     * @param ResponseInterface $response      - Response object
+     * @param bool              $is_authorized - TRUE or FALSE depending on whether the user authorized the access.
+     * @param mixed             $user_id       - Identifier of user who authorized the client
+     * @return ResponseInterface
+     *
+     * @see http://tools.ietf.org/html/rfc6749#section-4
+     *
+     * @ingroup oauth2_section_4
+     */
+    public function handleAuthorizeRequest(RequestInterface $request, ResponseInterface $response, $is_authorized, $user_id = null)
+    {
+        $this->response = $response;
+        $this->getAuthorizeController()->handleAuthorizeRequest($request, $this->response, $is_authorized, $user_id);
+
+        return $this->response;
+    }
+
+    /**
+     * Pull the authorization request data out of the HTTP request.
+     * - The redirect_uri is OPTIONAL as per draft 20. But your implementation can enforce it
+     *   by setting $config['enforce_redirect'] to true.
+     * - The state is OPTIONAL but recommended to enforce CSRF. Draft 21 states, however, that
+     *   CSRF protection is MANDATORY. You can enforce this by setting the $config['enforce_state'] to true.
+     *
+     * The draft specifies that the parameters should be retrieved from GET, override the Response
+     * object to change this
+     *
+     * @param RequestInterface  $request  - Request object
+     * @param ResponseInterface $response - Response object
+     * @return bool
+     *
+     * The authorization parameters so the authorization server can prompt
+     * the user for approval if valid.
+     *
+     * @see http://tools.ietf.org/html/rfc6749#section-4.1.1
+     * @see http://tools.ietf.org/html/rfc6749#section-10.12
+     *
+     * @ingroup oauth2_section_3
+     */
+    public function validateAuthorizeRequest(RequestInterface $request, ?ResponseInterface $response = null)
+    {
+        $this->response = is_null($response) ? new Response() : $response;
+        $value = $this->getAuthorizeController()->validateAuthorizeRequest($request, $this->response);
+
+        return $value;
+    }
+
+    /**
+     * @param RequestInterface  $request  - Request object
+     * @param ResponseInterface $response - Response object
+     * @param string            $scope    - Scope
+     * @return mixed
+     */
+    public function verifyResourceRequest(RequestInterface $request, ?ResponseInterface $response = null, $scope = null)
+    {
+        $this->response = is_null($response) ? new Response() : $response;
+        $value = $this->getResourceController()->verifyResourceRequest($request, $this->response, $scope);
+
+        return $value;
+    }
+
+    /**
+     * @param RequestInterface  $request  - Request object
+     * @param ResponseInterface $response - Response object
+     * @return mixed
+     */
+    public function getAccessTokenData(RequestInterface $request, ?ResponseInterface $response = null)
+    {
+        $this->response = is_null($response) ? new Response() : $response;
+        $value = $this->getResourceController()->getAccessTokenData($request, $this->response);
+
+        return $value;
+    }
+
+    /**
+     * @param GrantTypeInterface $grantType
+     * @param mixed              $identifier
+     */
+    public function addGrantType(GrantTypeInterface $grantType, $identifier = null)
+    {
+        if (!is_string($identifier)) {
+            $identifier = $grantType->getQueryStringIdentifier();
+        }
+
+        $this->grantTypes[$identifier] = $grantType;
+
+        // persist added grant type down to TokenController
+        if (!is_null($this->tokenController)) {
+            $this->getTokenController()->addGrantType($grantType, $identifier);
+        }
+    }
+
+    /**
+     * Set a storage object for the server
+     *
+     * @param object $storage - An object implementing one of the Storage interfaces
+     * @param mixed $key - If null, the storage is set to the key of each storage interface it implements
+     *
+     * @throws InvalidArgumentException
+     * @see storageMap
+     */
+    public function addStorage($storage, $key = null)
+    {
+        // if explicitly set to a valid key, do not "magically" set below
+        if (isset($this->storageMap[$key])) {
+            if (!is_null($storage) && !$storage instanceof $this->storageMap[$key]) {
+                throw new \InvalidArgumentException(sprintf('storage of type "%s" must implement interface "%s"', $key, $this->storageMap[$key]));
+            }
+            $this->storages[$key] = $storage;
+
+            // special logic to handle "client" and "client_credentials" strangeness
+            if ($key === 'client' && !isset($this->storages['client_credentials'])) {
+                if ($storage instanceof ClientCredentialsInterface) {
+                    $this->storages['client_credentials'] = $storage;
+                }
+            } elseif ($key === 'client_credentials' && !isset($this->storages['client'])) {
+                if ($storage instanceof ClientInterface) {
+                    $this->storages['client'] = $storage;
+                }
+            }
+        } elseif (!is_null($key) && !is_numeric($key)) {
+            throw new \InvalidArgumentException(sprintf('unknown storage key "%s", must be one of [%s]', $key, implode(', ', array_keys($this->storageMap))));
+        } else {
+            $set = false;
+            foreach ($this->storageMap as $type => $interface) {
+                if ($storage instanceof $interface) {
+                    $this->storages[$type] = $storage;
+                    $set = true;
+                }
+            }
+
+            if (!$set) {
+                throw new \InvalidArgumentException(sprintf('storage of class "%s" must implement one of [%s]', get_class($storage), implode(', ', $this->storageMap)));
+            }
+        }
+    }
+
+    /**
+     * @param ResponseTypeInterface $responseType
+     * @param mixed                 $key
+     *
+     * @throws InvalidArgumentException
+     */
+    public function addResponseType(ResponseTypeInterface $responseType, $key = null)
+    {
+        $key = $this->normalizeResponseType($key);
+
+        if (isset($this->responseTypeMap[$key])) {
+            if (!$responseType instanceof $this->responseTypeMap[$key]) {
+                throw new \InvalidArgumentException(sprintf('responseType of type "%s" must implement interface "%s"', $key, $this->responseTypeMap[$key]));
+            }
+            $this->responseTypes[$key] = $responseType;
+        } elseif (!is_null($key) && !is_numeric($key)) {
+            throw new \InvalidArgumentException(sprintf('unknown responseType key "%s", must be one of [%s]', $key, implode(', ', array_keys($this->responseTypeMap))));
+        } else {
+            $set = false;
+            foreach ($this->responseTypeMap as $type => $interface) {
+                if ($responseType instanceof $interface) {
+                    $this->responseTypes[$type] = $responseType;
+                    $set = true;
+                }
+            }
+
+            if (!$set) {
+                throw new \InvalidArgumentException(sprintf('Unknown response type %s.  Please implement one of [%s]', get_class($responseType), implode(', ', $this->responseTypeMap)));
+            }
+        }
+    }
+
+    /**
+     * @return ScopeInterface
+     */
+    public function getScopeUtil()
+    {
+        if (!$this->scopeUtil) {
+            $storage = isset($this->storages['scope']) ? $this->storages['scope'] : null;
+            $this->scopeUtil = new Scope($storage);
+        }
+
+        return $this->scopeUtil;
+    }
+
+    /**
+     * @param ScopeInterface $scopeUtil
+     */
+    public function setScopeUtil($scopeUtil)
+    {
+        $this->scopeUtil = $scopeUtil;
+    }
+
+    /**
+     * @return AuthorizeControllerInterface
+     * @throws LogicException
+     */
+    protected function createDefaultAuthorizeController()
+    {
+        if (!isset($this->storages['client'])) {
+            throw new \LogicException('You must supply a storage object implementing \OAuth2\Storage\ClientInterface to use the authorize server');
+        }
+        if (0 == count($this->responseTypes)) {
+            $this->responseTypes = $this->getDefaultResponseTypes();
+        }
+        if ($this->config['use_openid_connect'] && !isset($this->responseTypes['id_token'])) {
+            $this->responseTypes['id_token'] = $this->createDefaultIdTokenResponseType();
+            if ($this->config['allow_implicit']) {
+                $this->responseTypes['id_token token'] = $this->createDefaultIdTokenTokenResponseType();
+            }
+        }
+
+        $config = array_intersect_key($this->config, array_flip(explode(' ', 'allow_implicit enforce_state require_exact_redirect_uri enforce_pkce')));
+
+        if ($this->config['use_openid_connect']) {
+            return new OpenIDAuthorizeController($this->storages['client'], $this->responseTypes, $config, $this->getScopeUtil());
+        }
+
+        return new AuthorizeController($this->storages['client'], $this->responseTypes, $config, $this->getScopeUtil());
+    }
+
+    /**
+     * @return TokenControllerInterface
+     * @throws LogicException
+     */
+    protected function createDefaultTokenController()
+    {
+        if (0 == count($this->grantTypes)) {
+            $this->grantTypes = $this->getDefaultGrantTypes();
+        }
+
+        if (is_null($this->clientAssertionType)) {
+            // see if HttpBasic assertion type is requred.  If so, then create it from storage classes.
+            foreach ($this->grantTypes as $grantType) {
+                if (!$grantType instanceof ClientAssertionTypeInterface) {
+                    if (!isset($this->storages['client_credentials'])) {
+                        throw new \LogicException('You must supply a storage object implementing OAuth2\Storage\ClientCredentialsInterface to use the token server');
+                    }
+                    $config = array_intersect_key($this->config, array_flip(explode(' ', 'allow_credentials_in_request_body allow_public_clients')));
+                    $this->clientAssertionType = new HttpBasic($this->storages['client_credentials'], $config);
+                    break;
+                }
+            }
+        }
+
+        if (!isset($this->storages['client'])) {
+            throw new LogicException("You must supply a storage object implementing OAuth2\Storage\ClientInterface to use the token server");
+        }
+
+        $accessTokenResponseType = $this->getAccessTokenResponseType();
+
+        return new TokenController($accessTokenResponseType, $this->storages['client'], $this->grantTypes, $this->clientAssertionType, $this->getScopeUtil());
+    }
+
+    /**
+     * @return ResourceControllerInterface
+     * @throws LogicException
+     */
+    protected function createDefaultResourceController()
+    {
+        if ($this->config['use_jwt_access_tokens']) {
+            // overwrites access token storage with crypto token storage if "use_jwt_access_tokens" is set
+            if (!isset($this->storages['access_token']) || !$this->storages['access_token'] instanceof JwtAccessTokenInterface) {
+                $this->storages['access_token'] = $this->createDefaultJwtAccessTokenStorage();
+            }
+        } elseif (!isset($this->storages['access_token'])) {
+            throw new \LogicException('You must supply a storage object implementing OAuth2\Storage\AccessTokenInterface or use JwtAccessTokens to use the resource server');
+        }
+
+        if (!$this->tokenType) {
+            $this->tokenType = $this->getDefaultTokenType();
+        }
+
+        $config = array_intersect_key($this->config, array('www_realm' => ''));
+
+        return new ResourceController($this->tokenType, $this->storages['access_token'], $config, $this->getScopeUtil());
+    }
+
+    /**
+     * @return UserInfoControllerInterface
+     * @throws LogicException
+     */
+    protected function createDefaultUserInfoController()
+    {
+        if ($this->config['use_jwt_access_tokens']) {
+            // overwrites access token storage with crypto token storage if "use_jwt_access_tokens" is set
+            if (!isset($this->storages['access_token']) || !$this->storages['access_token'] instanceof JwtAccessTokenInterface) {
+                $this->storages['access_token'] = $this->createDefaultJwtAccessTokenStorage();
+            }
+        } elseif (!isset($this->storages['access_token'])) {
+            throw new \LogicException('You must supply a storage object implementing OAuth2\Storage\AccessTokenInterface or use JwtAccessTokens to use the UserInfo server');
+        }
+
+        if (!isset($this->storages['user_claims'])) {
+            throw new \LogicException('You must supply a storage object implementing OAuth2\OpenID\Storage\UserClaimsInterface to use the UserInfo server');
+        }
+
+        if (!$this->tokenType) {
+            $this->tokenType = $this->getDefaultTokenType();
+        }
+
+        $config = array_intersect_key($this->config, array('www_realm' => ''));
+
+        return new UserInfoController($this->tokenType, $this->storages['access_token'], $this->storages['user_claims'], $config, $this->getScopeUtil());
+    }
+
+    /**
+     * @return Bearer
+     */
+    protected function getDefaultTokenType()
+    {
+        $config = array_intersect_key($this->config, array_flip(explode(' ', 'token_param_name token_bearer_header_name')));
+
+        return new Bearer($config);
+    }
+
+    /**
+     * @return array
+     * @throws LogicException
+     */
+    protected function getDefaultResponseTypes()
+    {
+        $responseTypes = array();
+
+        if ($this->config['allow_implicit']) {
+            $responseTypes['token'] = $this->getAccessTokenResponseType();
+        }
+
+        if ($this->config['use_openid_connect']) {
+            $responseTypes['id_token'] = $this->getIdTokenResponseType();
+            if ($this->config['allow_implicit']) {
+                $responseTypes['id_token token'] = $this->getIdTokenTokenResponseType();
+            }
+        }
+
+        if (isset($this->storages['authorization_code'])) {
+            $config = array_intersect_key($this->config, array_flip(explode(' ', 'enforce_redirect auth_code_lifetime')));
+            if ($this->config['use_openid_connect']) {
+                if (!$this->storages['authorization_code'] instanceof OpenIDAuthorizationCodeInterface) {
+                    throw new \LogicException('Your authorization_code storage must implement OAuth2\OpenID\Storage\AuthorizationCodeInterface to work when "use_openid_connect" is true');
+                }
+                $responseTypes['code'] = new OpenIDAuthorizationCodeResponseType($this->storages['authorization_code'], $config);
+                $responseTypes['code id_token'] = new CodeIdToken($responseTypes['code'], $responseTypes['id_token']);
+            } else {
+                $responseTypes['code'] = new AuthorizationCodeResponseType($this->storages['authorization_code'], $config);
+            }
+        }
+
+        if (count($responseTypes) == 0) {
+            throw new \LogicException('You must supply an array of response_types in the constructor or implement a OAuth2\Storage\AuthorizationCodeInterface storage object or set "allow_implicit" to true and implement a OAuth2\Storage\AccessTokenInterface storage object');
+        }
+
+        return $responseTypes;
+    }
+
+    /**
+     * @return array
+     * @throws LogicException
+     */
+    protected function getDefaultGrantTypes()
+    {
+        $grantTypes = array();
+
+        if (isset($this->storages['user_credentials'])) {
+            $grantTypes['password'] = new UserCredentials($this->storages['user_credentials']);
+        }
+
+        if (isset($this->storages['client_credentials'])) {
+            $config = array_intersect_key($this->config, array('allow_credentials_in_request_body' => ''));
+            $grantTypes['client_credentials'] = new ClientCredentials($this->storages['client_credentials'], $config);
+        }
+
+        if (isset($this->storages['refresh_token'])) {
+            $config = array_intersect_key($this->config, array_flip(explode(' ', 'always_issue_new_refresh_token unset_refresh_token_after_use')));
+            $grantTypes['refresh_token'] = new RefreshToken($this->storages['refresh_token'], $config);
+        }
+
+        if (isset($this->storages['authorization_code'])) {
+            if ($this->config['use_openid_connect']) {
+                if (!$this->storages['authorization_code'] instanceof OpenIDAuthorizationCodeInterface) {
+                    throw new \LogicException('Your authorization_code storage must implement OAuth2\OpenID\Storage\AuthorizationCodeInterface to work when "use_openid_connect" is true');
+                }
+                $grantTypes['authorization_code'] = new OpenIDAuthorizationCodeGrantType($this->storages['authorization_code']);
+            } else {
+                $grantTypes['authorization_code'] = new AuthorizationCode($this->storages['authorization_code']);
+            }
+        }
+
+        if (count($grantTypes) == 0) {
+            throw new \LogicException('Unable to build default grant types - You must supply an array of grant_types in the constructor');
+        }
+
+        return $grantTypes;
+    }
+
+    /**
+     * @return AccessToken
+     */
+    protected function getAccessTokenResponseType()
+    {
+        if (isset($this->responseTypes['token'])) {
+            return $this->responseTypes['token'];
+        }
+
+        if ($this->config['use_jwt_access_tokens']) {
+            return $this->createDefaultJwtAccessTokenResponseType();
+        }
+
+        return $this->createDefaultAccessTokenResponseType();
+    }
+
+    /**
+     * @return IdToken
+     */
+    protected function getIdTokenResponseType()
+    {
+        if (isset($this->responseTypes['id_token'])) {
+            return $this->responseTypes['id_token'];
+        }
+
+        return $this->createDefaultIdTokenResponseType();
+    }
+
+    /**
+     * @return IdTokenToken
+     */
+    protected function getIdTokenTokenResponseType()
+    {
+        if (isset($this->responseTypes['id_token token'])) {
+            return $this->responseTypes['id_token token'];
+        }
+
+        return $this->createDefaultIdTokenTokenResponseType();
+    }
+
+    /**
+     * For Resource Controller
+     *
+     * @return JwtAccessTokenStorage
+     * @throws LogicException
+     */
+    protected function createDefaultJwtAccessTokenStorage()
+    {
+        if (!isset($this->storages['public_key'])) {
+            throw new \LogicException('You must supply a storage object implementing OAuth2\Storage\PublicKeyInterface to use crypto tokens');
+        }
+        $tokenStorage = null;
+        if (!empty($this->config['store_encrypted_token_string']) && isset($this->storages['access_token'])) {
+            $tokenStorage = $this->storages['access_token'];
+        }
+        // wrap the access token storage as required.
+        return new JwtAccessTokenStorage($this->storages['public_key'], $tokenStorage);
+    }
+
+    /**
+     * For Authorize and Token Controllers
+     *
+     * @return JwtAccessToken
+     * @throws LogicException
+     */
+    protected function createDefaultJwtAccessTokenResponseType()
+    {
+        if (!isset($this->storages['public_key'])) {
+            throw new \LogicException('You must supply a storage object implementing OAuth2\Storage\PublicKeyInterface to use crypto tokens');
+        }
+
+        $tokenStorage = null;
+        if (isset($this->storages['access_token'])) {
+            $tokenStorage = $this->storages['access_token'];
+        }
+
+        $refreshStorage = null;
+        if (isset($this->storages['refresh_token'])) {
+            $refreshStorage = $this->storages['refresh_token'];
+        }
+
+        $config = array_intersect_key($this->config, array_flip(explode(' ', 'store_encrypted_token_string issuer access_lifetime refresh_token_lifetime jwt_extra_payload_callable')));
+
+        return new JwtAccessToken($this->storages['public_key'], $tokenStorage, $refreshStorage, $config);
+    }
+
+    /**
+     * @return AccessToken
+     * @throws LogicException
+     */
+    protected function createDefaultAccessTokenResponseType()
+    {
+        if (!isset($this->storages['access_token'])) {
+            throw new LogicException("You must supply a response type implementing OAuth2\ResponseType\AccessTokenInterface, or a storage object implementing OAuth2\Storage\AccessTokenInterface to use the token server");
+        }
+
+        $refreshStorage = null;
+        if (isset($this->storages['refresh_token'])) {
+            $refreshStorage = $this->storages['refresh_token'];
+        }
+
+        $config = array_intersect_key($this->config, array_flip(explode(' ', 'access_lifetime refresh_token_lifetime')));
+        $config['token_type'] = $this->tokenType ? $this->tokenType->getTokenType() :  $this->getDefaultTokenType()->getTokenType();
+
+        return new AccessToken($this->storages['access_token'], $refreshStorage, $config);
+    }
+
+    /**
+     * @return IdToken
+     * @throws LogicException
+     */
+    protected function createDefaultIdTokenResponseType()
+    {
+        if (!isset($this->storages['user_claims'])) {
+            throw new LogicException("You must supply a storage object implementing OAuth2\OpenID\Storage\UserClaimsInterface to use openid connect");
+        }
+        if (!isset($this->storages['public_key'])) {
+            throw new LogicException("You must supply a storage object implementing OAuth2\Storage\PublicKeyInterface to use openid connect");
+        }
+
+        $config = array_intersect_key($this->config, array_flip(explode(' ', 'issuer id_lifetime')));
+
+        return new IdToken($this->storages['user_claims'], $this->storages['public_key'], $config);
+    }
+
+    /**
+     * @return IdTokenToken
+     */
+    protected function createDefaultIdTokenTokenResponseType()
+    {
+        return new IdTokenToken($this->getAccessTokenResponseType(), $this->getIdTokenResponseType());
+    }
+
+    /**
+     * @throws InvalidArgumentException
+     */
+    protected function validateOpenIdConnect()
+    {
+        $authCodeGrant = $this->getGrantType('authorization_code');
+        if (!empty($authCodeGrant) && !$authCodeGrant instanceof OpenIDAuthorizationCodeGrantType) {
+            throw new \InvalidArgumentException('You have enabled OpenID Connect, but supplied a grant type that does not support it.');
+        }
+    }
+
+    /**
+     * @param string $name
+     * @return string
+     */
+    protected function normalizeResponseType($name)
+    {
+        // for multiple-valued response types - make them alphabetical
+        if (!empty($name) && false !== strpos($name, ' ')) {
+            $types = explode(' ', $name);
+            sort($types);
+            $name = implode(' ', $types);
+        }
+
+        return $name;
+    }
+
+    /**
+     * @return mixed
+     */
+    public function getResponse()
+    {
+        return $this->response;
+    }
+
+    /**
+     * @return array
+     */
+    public function getStorages()
+    {
+        return $this->storages;
+    }
+
+    /**
+     * @param string $name
+     * @return object|null
+     */
+    public function getStorage($name)
+    {
+        return isset($this->storages[$name]) ? $this->storages[$name] : null;
+    }
+
+    /**
+     * @return array
+     */
+    public function getGrantTypes()
+    {
+        return $this->grantTypes;
+    }
+
+    /**
+     * @param string $name
+     * @return object|null
+     */
+    public function getGrantType($name)
+    {
+        return isset($this->grantTypes[$name]) ? $this->grantTypes[$name] : null;
+    }
+
+    /**
+     * @return array
+     */
+    public function getResponseTypes()
+    {
+        return $this->responseTypes;
+    }
+
+    /**
+     * @param string $name
+     * @return object|null
+     */
+    public function getResponseType($name)
+    {
+        // for multiple-valued response types - make them alphabetical
+        $name = $this->normalizeResponseType($name);
+
+        return isset($this->responseTypes[$name]) ? $this->responseTypes[$name] : null;
+    }
+
+    /**
+     * @return TokenTypeInterface
+     */
+    public function getTokenType()
+    {
+        return $this->tokenType;
+    }
+
+    /**
+     * @return ClientAssertionTypeInterface
+     */
+    public function getClientAssertionType()
+    {
+        return $this->clientAssertionType;
+    }
+
+    /**
+     * @param string $name
+     * @param mixed $value
+     */
+    public function setConfig($name, $value)
+    {
+        $this->config[$name] = $value;
+    }
+
+    /**
+     * @param string $name
+     * @param mixed $default
+     * @return mixed
+     */
+    public function getConfig($name, $default = null)
+    {
+        return isset($this->config[$name]) ? $this->config[$name] : $default;
+    }
+}

+ 87 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/bshaffer/oauth2-server-php/src/OAuth2/Storage/JwtAccessToken.php

@@ -0,0 +1,87 @@
+<?php
+
+namespace OAuth2\Storage;
+
+use OAuth2\Encryption\EncryptionInterface;
+use OAuth2\Encryption\Jwt;
+
+/**
+ * @author Brent Shaffer <bshafs at gmail dot com>
+ */
+class JwtAccessToken implements JwtAccessTokenInterface
+{
+    protected $publicKeyStorage;
+    protected $tokenStorage;
+    protected $encryptionUtil;
+
+    /**
+     * @param OAuth2\Encryption\PublicKeyInterface  $publicKeyStorage the public key encryption to use
+     * @param OAuth2\Storage\AccessTokenInterface   $tokenStorage     OPTIONAL persist the access token to another storage. This is useful if
+     *                                                                you want to retain access token grant information somewhere, but
+     *                                                                is not necessary when using this grant type.
+     * @param OAuth2\Encryption\EncryptionInterface $encryptionUtil   OPTIONAL class to use for "encode" and "decode" functions.
+     */
+    public function __construct(PublicKeyInterface $publicKeyStorage, ?AccessTokenInterface $tokenStorage = null, ?EncryptionInterface $encryptionUtil = null)
+    {
+        $this->publicKeyStorage = $publicKeyStorage;
+        $this->tokenStorage = $tokenStorage;
+        if (is_null($encryptionUtil)) {
+            $encryptionUtil = new Jwt;
+        }
+        $this->encryptionUtil = $encryptionUtil;
+    }
+
+    public function getAccessToken($oauth_token)
+    {
+        // just decode the token, don't verify
+        if (!$tokenData = $this->encryptionUtil->decode($oauth_token, null, false)) {
+            return false;
+        }
+
+        $client_id  = isset($tokenData['aud']) ? $tokenData['aud'] : null;
+        $public_key = $this->publicKeyStorage->getPublicKey($client_id);
+        $algorithm  = $this->publicKeyStorage->getEncryptionAlgorithm($client_id);
+
+        // now that we have the client_id, verify the token
+        if (false === $this->encryptionUtil->decode($oauth_token, $public_key, array($algorithm))) {
+            return false;
+        }
+
+        // normalize the JWT claims to the format expected by other components in this library
+        return $this->convertJwtToOAuth2($tokenData);
+    }
+
+    public function setAccessToken($oauth_token, $client_id, $user_id, $expires, $scope = null)
+    {
+        if ($this->tokenStorage) {
+            return $this->tokenStorage->setAccessToken($oauth_token, $client_id, $user_id, $expires, $scope);
+        }
+    }
+
+    public function unsetAccessToken($access_token)
+    {
+        if ($this->tokenStorage) {
+            return $this->tokenStorage->unsetAccessToken($access_token);
+        }
+    }
+
+
+    // converts a JWT access token into an OAuth2-friendly format
+    protected function convertJwtToOAuth2($tokenData)
+    {
+        $keyMapping = array(
+            'aud' => 'client_id',
+            'exp' => 'expires',
+            'sub' => 'user_id'
+        );
+
+        foreach ($keyMapping as $jwtKey => $oauth2Key) {
+            if (isset($tokenData[$jwtKey])) {
+                $tokenData[$oauth2Key] = $tokenData[$jwtKey];
+                unset($tokenData[$jwtKey]);
+            }
+        }
+
+        return $tokenData;
+    }
+}

+ 82 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Environment.php

@@ -0,0 +1,82 @@
+<?php
+
+namespace ChargeBee\ChargeBee;
+
+class Environment
+{
+    private $apiKey;
+    private $site;
+    private $apiEndPoint;
+
+    private static $default_env;
+    public static $scheme = "https";
+    public static $chargebeeDomain = "chargebee.com";
+    public static $userAgentSuffix = "";
+
+    public static $connectTimeoutInSecs = 30;
+    public static $requestTimeoutInSecs = 80;
+
+    public static $timeMachineWaitInSecs = 3;
+    public static $exportWaitInSecs = 3;
+
+    const API_VERSION = "v2";
+
+    public function __construct($site, $apiKey)
+    {
+        $this->site = $site;
+        $this->apiKey = $apiKey;
+        $this->apiEndPoint = self::$scheme . "://$site." . self::$chargebeeDomain . "/api/" . self::API_VERSION;
+    }
+
+    public static function configure($site, $apiKey)
+    {
+        self::$default_env = new self($site, $apiKey);
+    }
+
+    public function getApiKey()
+    {
+        return $this->apiKey;
+    }
+
+    public function getSite()
+    {
+        return $this->site;
+    }
+
+    public function getApiEndpoint()
+    {
+        return $this->apiEndPoint;
+    }
+
+    public static function defaultEnv()
+    {
+        return self::$default_env;
+    }
+
+    private function getSubDomainApiUrl($subDomain)
+    {
+        return self::$scheme . "://" . self::getSite() . "." . $subDomain . "." . self::$chargebeeDomain . "/api/" . self::API_VERSION;
+    }
+
+    public function apiUrl($url, $subDomain = null)
+    {
+      if($subDomain != null) {
+          return self::getSubDomainApiUrl($subDomain) . $url;
+      }
+      return $this->apiEndPoint . $url;
+    }
+
+    public static function updateConnectTimeoutInSecs($connectTimeout)
+    {
+        self::$connectTimeoutInSecs = $connectTimeout;
+    }
+
+    public static function updateRequestTimeoutInSecs($requestTimeout)
+    {
+        self::$requestTimeoutInSecs = $requestTimeout;
+    }
+
+    public static function setUserAgentSuffix($suffix){
+        self::$userAgentSuffix = $suffix;
+    }
+}

+ 79 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Exceptions/APIError.php

@@ -0,0 +1,79 @@
+<?php
+
+namespace ChargeBee\ChargeBee\Exceptions;
+
+use Exception;
+
+class APIError extends Exception
+{
+    private $httpStatusCode;
+    private $jsonObject;
+    private $type;
+    private $apiErrorCode;
+    private $param;
+    private $headers;
+    private $errorCauseId;
+    public function __construct($httpStatusCode, $jsonObject, $responseHeaders)
+    {
+        parent::__construct($jsonObject['message']);
+
+        $this->jsonObject = $jsonObject;
+        $this->type = isset($jsonObject['type']) ? $jsonObject['type'] : null;
+        $this->apiErrorCode = $jsonObject['api_error_code'];
+        $this->param = isset($jsonObject['param']) ? $jsonObject['param'] : null;
+        $this->errorCauseId = isset($jsonObject['error_cause_id']) ? $jsonObject['error_cause_id'] : null;
+        $this->httpStatusCode = $httpStatusCode;
+        $this->headers = $responseHeaders;
+    }
+
+    public function getHttpStatusCode()
+    {
+        return $this->httpStatusCode;
+    }
+
+    public function getType()
+    {
+        return $this->type;
+    }
+
+    public function getApiErrorCode()
+    {
+        return $this->apiErrorCode;
+    }
+
+    public function getParam()
+    {
+        return $this->param;
+    }
+
+    /**
+     * This function has been deprecated. Use getHttpStatusCode.
+     * @deprecated
+     */
+    public function getHttpCode()
+    {
+        return $this->httpStatusCode;
+    }
+
+    /**
+     * This function has been deprecated. There IO errors are now thrown as
+     * IOException.
+     * @deprecated
+     */
+    public function getErrorNo()
+    {
+        return 0;
+    }
+
+    public function getJsonObject()
+    {
+        return $this->jsonObject;
+    }
+    public function getHeaders()
+    {
+        return $this->headers;
+    }
+    public function getErrorCauseId(){
+        return $this->errorCauseId;
+    }
+}

+ 162 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Guzzle.php

@@ -0,0 +1,162 @@
+<?php
+
+
+namespace ChargeBee\ChargeBee;
+
+use ChargeBee\ChargeBee;
+use ChargeBee\ChargeBee\Exceptions\IOException;
+use ChargeBee\ChargeBee\Exceptions\PaymentException;
+use ChargeBee\ChargeBee\Exceptions\OperationFailedException;
+use ChargeBee\ChargeBee\Exceptions\InvalidRequestException;
+use ChargeBee\ChargeBee\Exceptions\APIError;
+use Exception;
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\RequestException;
+
+class Guzzle
+{
+    public static function utf8($value) {
+        if (is_string($value))
+            return mb_convert_encoding($value, "UTF-8");
+        else
+            return $value;
+    }
+
+    public static function doRequest($meth, $url, $env, $params = array(), $headers = array(), $subDomain = null, $isJsonRequest=false) {
+        list($response, $httpCode, $responseHeaders) = self::request($meth, $url, $env, $params, $headers, $subDomain, $isJsonRequest);
+        $respJson = self::processResponse($response, $httpCode, $responseHeaders);
+        $response = new Response($respJson, $responseHeaders, $httpCode);
+        return $response;
+    }
+    
+    public static function request($meth, $url, $env, $params, $headers, $subDomain = null, $isJsonRequest=false) {
+        $client = new Client();
+
+        $opts = array(
+            'connect_timeout' => Environment::$connectTimeoutInSecs,
+            'timeout' => Environment::$requestTimeoutInSecs,
+            'allow_redirects' => true,
+            'http_errors' => false
+        );
+        if ($meth == Request::GET) {
+            if (count($params) > 0) {
+                $opts['query'] = $params;
+            }
+        } else if ($meth == Request::POST) {
+            if ($isJsonRequest) {
+                $params = json_encode($params);
+                $opts['body'] = $params;
+            } else {
+                $opts['form_params'] = $params;
+            }
+        } else {
+            throw new Exception("Invalid http method $meth");
+        }
+        $url = self::utf8($env->apiUrl($url, $subDomain));
+        $contentType = $isJsonRequest ? 'application/json' : 'application/x-www-form-urlencoded';
+        $userAgent = "Chargebee-PHP-Client" . " v" . Version::VERSION;
+        if (!empty(Environment::$userAgentSuffix)) {
+            $userAgent .= "; " . Environment::$userAgentSuffix;
+        }
+        $httpHeaders = array_merge($headers, ['Accept' => 'application/json', 'User-Agent' => $userAgent, 'Lang-Version' => phpversion() , 'OS-Version' => PHP_OS, 'Content-Type' => $contentType]);
+
+        $opts['headers'] = $httpHeaders;
+        $opts['auth'] = [$env->getApiKey(), ''];
+
+        // Specifying a CA bundle results in the following error when running in Google App Engine:
+        // "Unsupported SSL context options are set. The following options are present, but have been ignored: allow_self_signed, cafile"
+        // https://cloud.google.com/appengine/docs/php/outbound-requests#secure_connections_and_https
+        $opts['verify'] = ChargeBee::getVerifyCaCerts() && !self::isAppEngine() ? ChargeBee::getCaCertPath() : false;
+
+        $response = null;
+        try {
+            $response = $client->request($meth, $url, $opts);
+        } catch (RequestException $e) {
+            $errno = $e->getCode();
+            $guzzleMsg = $e->getMessage();
+            $message = "IO exception occurred when trying to connect to " . $url . " . Reason : " . $guzzleMsg;
+            throw new IOException($message, $errno);
+        }
+        $responseHeaders = $response->getHeaders();
+        $httpCode = $response->getStatusCode();
+        return array((string)$response->getBody(), $httpCode, $responseHeaders);
+    }
+
+    /**
+     * Recommended way to check if script is running in Google App Engine:
+     * https://github.com/google/google-api-php-client/blob/master/src/Google/Client.php#L799
+     *
+     * @return bool Returns true if running in Google App Engine
+     */
+    private static function isAppEngine() {
+        return (isset($_SERVER['SERVER_SOFTWARE']) && strpos($_SERVER['SERVER_SOFTWARE'], 'Google App Engine') !== false);
+    }
+
+    /**
+     * @param $response
+     * @param $httpCode
+     *
+     * @return mixed
+     * @throws APIError
+     * @throws InvalidRequestException
+     * @throws OperationFailedException
+     * @throws PaymentException
+     */
+    public static function processResponse($response, $httpCode, $responseHeaders){
+        $respJson = json_decode($response, true);
+        if(!$respJson && ($httpCode < 200 || $httpCode > 299)){
+            if (strpos($response, '503') !== false)
+                 throw new Exception("Sorry, the server is currently unable to handle the request due to a temporary overload or scheduled maintenance. Please retry after sometime. \n type: internal_temporary_error, \n http_status_code: 503, \n error_code: internal_temporary_error");
+            else if (strpos($response, '504') !== false)
+                 throw new Exception("The server did not receive a timely response from an upstream server, request aborted. If this problem persists, contact us at support@chargebee.com. \n type: gateway_timeout, \n http_status_code: 504, \n error_code: gateway_timeout");
+            else          
+                 throw new Exception("Sorry, something went wrong when trying to process the request. If this problem persists, contact us at support@chargebee.com. \n type: internal_error, \n http_status_code: 500, \n error_code: internal_error ");
+        }
+        if ($httpCode < 200 || $httpCode > 299) {
+            self::handleAPIRespError($httpCode, $respJson, $response, $responseHeaders);
+        }
+        return $respJson;
+    }
+
+    /**
+     * @param $httpCode
+     * @param $respJson
+     * @param $response
+     *
+     * @throws APIError
+     * @throws InvalidRequestException
+     * @throws OperationFailedException
+     * @throws PaymentException
+     */
+    public static function handleAPIRespError($httpCode, $respJson, $response, $responseHeaders){
+        if (!isset($respJson['api_error_code'])) {
+            throw new Exception("No api_error_code attribute in content. Probably not a ChargeBee's error response. The content is \n " . $response);
+        }
+        $type = "unknown";
+        if (isset($respJson['type'])) {
+            $type = $respJson['type'];
+        }
+        if ($type == "payment") {
+            throw new PaymentException($httpCode, $respJson, $responseHeaders);
+        } elseif ($type == "operation_failed") {
+            throw new OperationFailedException($httpCode, $respJson, $responseHeaders);
+        } elseif ($type == "invalid_request") {
+            throw new InvalidRequestException($httpCode, $respJson, $responseHeaders);
+        } else {
+            throw new APIError($httpCode, $respJson, $responseHeaders);
+        }
+    }
+
+}
+
+class Response {
+    public $data;
+    public $headers;
+    public $httpStatusCode;
+
+    public function __construct($data, $headers, $httpStatusCode) {
+        $this->data = $data;
+        $this->headers = $headers;
+        $this->httpStatusCode = $httpStatusCode;
+    }
+}

+ 121 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/ListResult.php

@@ -0,0 +1,121 @@
+<?php
+
+namespace ChargeBee\ChargeBee;
+
+use Countable;
+use ArrayAccess;
+use Iterator;
+
+class ListResult implements Countable, ArrayAccess, Iterator
+{
+    private $response;
+
+    private $nextOffset;
+
+    protected $_items;
+
+    private $_responseHeaders;
+    private $_responseStatusCode;
+
+    private $_index = 0;
+
+    public function __construct($response, $nextOffset, $_responseHeaders = null, $httpStatusCode = null)
+    {
+        $this->response = $response;
+        $this->nextOffset = $nextOffset;
+        $this->_responseHeaders = $_responseHeaders;
+        $this->_items = [];
+        $this->_responseStatusCode = $httpStatusCode;
+        $this->_initItems();
+    }
+    public function getResponseStatusCode(){
+        return $this->_responseStatusCode;
+    }
+    public function getResponseHeaders()
+    {
+        return $this->_responseHeaders;
+    }
+
+    private function _initItems()
+    {
+        foreach ($this->response as $r) {
+            array_push($this->_items, new Result($r));
+        }
+    }
+
+    public function nextOffset()
+    {
+        return $this->nextOffset;
+    }
+
+    #[\ReturnTypeWillChange]
+    public function count()
+    {
+        return count($this->_items);
+    }
+
+    //Implementation for ArrayAccess functions
+    #[\ReturnTypeWillChange]
+    public function offsetSet($k, $v)
+    {
+        $this->_items[$k] = $v;
+    }
+
+    #[\ReturnTypeWillChange]
+    public function offsetExists($k)
+    {
+        return isset($this->_items[$k]);
+    }
+
+    #[\ReturnTypeWillChange]
+    public function offsetUnset($k)
+    {
+        unset($this->_items[$k]);
+    }
+
+    #[\ReturnTypeWillChange]
+    public function offsetGet($k)
+    {
+        return isset($this->_items[$k]) ? $this->_items[$k] : null;
+    }
+
+    //Implementation for Iterator functions
+    #[\ReturnTypeWillChange]
+    public function current()
+    {
+        return $this->_items[$this->_index];
+    }
+
+    #[\ReturnTypeWillChange]
+    public function key()
+    {
+        return $this->_index;
+    }
+
+    #[\ReturnTypeWillChange]
+    public function next()
+    {
+        ++$this->_index;
+    }
+
+    #[\ReturnTypeWillChange]
+    public function rewind()
+    {
+        $this->_index = 0;
+    }
+
+    #[\ReturnTypeWillChange]
+    public function valid()
+    {
+        if ($this->_index < count($this->_items)) {
+            return true;
+        } else {
+            return false;
+        }
+    }
+
+    public function toJson()
+    {
+        return json_encode($this->response);
+    }
+}

+ 171 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Model.php

@@ -0,0 +1,171 @@
+<?php
+
+namespace ChargeBee\ChargeBee;
+
+use Exception;
+
+class Model
+{
+    protected $_values;
+
+    protected $_subTypes;
+    protected $_dependantTypes;
+
+    protected $_data = [];
+
+    protected $allowed = [];
+
+    public function __construct($values, $subTypes = [], $dependantTypes = [])
+    {
+        $this->_values = $values;
+        $this->_subTypes = $subTypes;
+        $this->_dependantTypes=$dependantTypes;
+        $this->_load();
+    }
+
+    public function param($k)
+    {
+        if (array_key_exists($k, $this->_values)) {
+            return $this->_values[$k];
+        } else {
+            throw new Exception("Unknown param $k in " . get_class($this));
+        }
+    }
+
+    public function getValues()
+    {
+        return $this->_values;
+    }
+
+    public function toJson()
+    {
+        return json_encode($this->_values);
+    }
+
+    public function __set($k, $v)
+    {
+        $this->_data[$k] = $v;
+    }
+
+    public function __isset($k)
+    {
+        return isset($this->_data[$k]);
+    }
+
+    public function __unset($k)
+    {
+        unset($this->data[$k]);
+    }
+
+    public function __get($k)
+    {
+        $childClassName = get_class($this);
+        if ($childClassName === "ChargeBee\ChargeBee\Models\Subscription" && $k === "metadata") {
+            // Trigger a deprecation notice when accessing 'metadata' for subscription.
+            trigger_error("The 'metadata' field is deprecated. Please use metaData", E_USER_WARNING);
+            return null;
+        }
+        if (isset($this->_data[$k])) {
+            return $this->_data[$k];
+        } elseif (in_array($k, $this->allowed)) {
+            return null;
+        } elseif (substr($k, 0, 2) == "cf") { //All the custom fields start with prefix cf.
+            return null;
+        } else {
+            throw new Exception("Unknown property $k in " . get_class($this));
+        }
+    }
+
+    private function __getDependant($k)
+    {
+        if (isset($this->_dependantTypes[$k])) {
+            return $this->_dependantTypes[$k];
+        } elseif (in_array($k, $this->allowed)) {
+            return null;
+        } else {
+            throw new Exception("Unknown property $k in " . get_class($this));
+        }
+    }
+
+    private function isHash($array)
+    {
+        if (!is_array($array) || count($array) === 0) {
+            return false;
+        }
+
+        foreach (array_keys($array) as $k) {
+            if (is_numeric($k)) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    public function _load()
+    {
+        foreach ($this->_values as $k => $v) {
+            $setVal = null;
+
+            if ($this->isHash($v) && array_key_exists($k, $this->_dependantTypes)) {
+                continue;
+            }
+
+            if ($this->isHash($v) && array_key_exists($k, $this->_subTypes)) {
+                $setVal = new $this->_subTypes[$k]($v);
+            } elseif (is_array($v) && array_key_exists($k, $this->_subTypes)) {
+                $setVal = [];
+                foreach ($v as $stV) {
+                    array_push($setVal, new $this->_subTypes[$k]($stV));
+                }
+            }
+
+            if (is_null($setVal)) {
+                $setVal = $v;
+            }
+
+            $this->_data[Util::toCamelCaseFromUnderscore($k)] = $setVal;
+        }
+    }
+
+    public function _initDependant($obj, $type, $subTypes = [])
+    {
+        if (!array_key_exists($type, $obj)) {
+            return $this;
+        }
+
+        $class = $this->__getDependant($type);
+
+        if ($this->isHash($obj) && !is_null($class)) {
+            $dependantObj = new $class($obj[$type], $subTypes);
+            $this->_data[Util::toCamelCaseFromUnderscore($type)] = $dependantObj;
+        }
+
+        return $this;
+    }
+
+    public function _initDependantList($obj, $type, $subTypes = [])
+    {
+        if (!array_key_exists($type, $obj)) {
+            return $this;
+        }
+
+        if (!is_array($obj[$type])) {
+            return $this;
+        }
+
+        $class=$this->__getDependant($type);
+
+        if (!is_null($class)) {
+            $setVal = [];
+
+            foreach ($obj[$type] as $dt) {
+                array_push($setVal, new $class($dt, $subTypes));
+            }
+
+            $this->_data[Util::toCamelCaseFromUnderscore($type)] = $setVal;
+        }
+
+        return $this;
+    }
+}

+ 117 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/Addon.php

@@ -0,0 +1,117 @@
+<?php
+
+namespace ChargeBee\ChargeBee\Models;
+
+use ChargeBee\ChargeBee\Model;
+use ChargeBee\ChargeBee\Request;
+use ChargeBee\ChargeBee\Util;
+
+class Addon extends Model
+{
+
+  protected $allowed = [
+    'id',
+    'name',
+    'invoiceName',
+    'description',
+    'pricingModel',
+    'type',
+    'chargeType',
+    'price',
+    'currencyCode',
+    'period',
+    'periodUnit',
+    'unit',
+    'status',
+    'archivedAt',
+    'enabledInPortal',
+    'taxCode',
+    'hsnCode',
+    'taxjarProductCode',
+    'avalaraSaleType',
+    'avalaraTransactionType',
+    'avalaraServiceType',
+    'sku',
+    'accountingCode',
+    'accountingCategory1',
+    'accountingCategory2',
+    'accountingCategory3',
+    'accountingCategory4',
+    'isShippable',
+    'shippingFrequencyPeriod',
+    'shippingFrequencyPeriodUnit',
+    'resourceVersion',
+    'updatedAt',
+    'priceInDecimal',
+    'includedInMrr',
+    'channel',
+    'prorationType',
+    'invoiceNotes',
+    'taxable',
+    'taxProfileId',
+    'metaData',
+    'tiers',
+    'taxProvidersFields',
+    'showDescriptionInInvoices',
+    'showDescriptionInQuotes',
+  ];
+
+
+
+  # OPERATIONS
+  #-----------
+
+  public static function create($params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+        "metaData" => 0,
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("addons"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function update($id, $params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+        "metaData" => 0,
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("addons",$id), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function all($params = array(), $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::sendListRequest(Request::GET, Util::encodeURIPath("addons"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function retrieve($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::GET, Util::encodeURIPath("addons",$id), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function delete($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("addons",$id,"delete"), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function copy($params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("addons","copy"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function unarchive($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("addons",$id,"unarchive"), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+ }
+
+?>

+ 52 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/Address.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace ChargeBee\ChargeBee\Models;
+
+use ChargeBee\ChargeBee\Model;
+use ChargeBee\ChargeBee\Request;
+use ChargeBee\ChargeBee\Util;
+
+class Address extends Model
+{
+
+  protected $allowed = [
+    'label',
+    'firstName',
+    'lastName',
+    'email',
+    'company',
+    'phone',
+    'addr',
+    'extendedAddr',
+    'extendedAddr2',
+    'city',
+    'stateCode',
+    'state',
+    'country',
+    'zip',
+    'validationStatus',
+    'subscriptionId',
+  ];
+
+
+
+  # OPERATIONS
+  #-----------
+
+  public static function retrieve($params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::GET, Util::encodeURIPath("addresses"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function update($params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("addresses"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+ }
+
+?>

+ 73 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/AttachedItem.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace ChargeBee\ChargeBee\Models;
+
+use ChargeBee\ChargeBee\Model;
+use ChargeBee\ChargeBee\Request;
+use ChargeBee\ChargeBee\Util;
+
+class AttachedItem extends Model
+{
+
+  protected $allowed = [
+    'id',
+    'parentItemId',
+    'itemId',
+    'type',
+    'status',
+    'quantity',
+    'quantityInDecimal',
+    'billingCycles',
+    'chargeOnEvent',
+    'chargeOnce',
+    'createdAt',
+    'resourceVersion',
+    'updatedAt',
+    'channel',
+    'businessEntityId',
+    'deleted',
+  ];
+
+
+
+  # OPERATIONS
+  #-----------
+
+  public static function create($id, $params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("items",$id,"attached_items"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function update($id, $params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("attached_items",$id), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function retrieve($id, $params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::GET, Util::encodeURIPath("attached_items",$id), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function delete($id, $params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("attached_items",$id,"delete"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function all($id, $params = array(), $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::sendListRequest(Request::GET, Util::encodeURIPath("items",$id,"attached_items"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+ }
+
+?>

+ 43 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/BusinessEntity.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace ChargeBee\ChargeBee\Models;
+
+use ChargeBee\ChargeBee\Model;
+use ChargeBee\ChargeBee\Request;
+use ChargeBee\ChargeBee\Util;
+
+class BusinessEntity extends Model
+{
+
+  protected $allowed = [
+    'id',
+    'name',
+    'status',
+    'deleted',
+    'createdAt',
+    'resourceVersion',
+    'updatedAt',
+  ];
+
+
+
+  # OPERATIONS
+  #-----------
+
+  public static function createTransfers($params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("business_entities","transfers"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function getTransfers($params = array(), $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::GET, Util::encodeURIPath("business_entities","transfers"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+ }
+
+?>

+ 85 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/Card.php

@@ -0,0 +1,85 @@
+<?php
+
+namespace ChargeBee\ChargeBee\Models;
+
+use ChargeBee\ChargeBee\Model;
+use ChargeBee\ChargeBee\Request;
+use ChargeBee\ChargeBee\Util;
+
+class Card extends Model
+{
+
+  protected $allowed = [
+    'paymentSourceId',
+    'status',
+    'gateway',
+    'gatewayAccountId',
+    'refTxId',
+    'firstName',
+    'lastName',
+    'iin',
+    'last4',
+    'cardType',
+    'fundingType',
+    'expiryMonth',
+    'expiryYear',
+    'issuingCountry',
+    'billingAddr1',
+    'billingAddr2',
+    'billingCity',
+    'billingStateCode',
+    'billingState',
+    'billingCountry',
+    'billingZip',
+    'createdAt',
+    'resourceVersion',
+    'updatedAt',
+    'ipAddress',
+    'poweredBy',
+    'customerId',
+    'maskedNumber',
+  ];
+
+
+
+  # OPERATIONS
+  #-----------
+
+  public static function retrieve($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::GET, Util::encodeURIPath("cards",$id), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function updateCardForCustomer($id, $params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("customers",$id,"credit_card"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function switchGatewayForCustomer($id, $params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("customers",$id,"switch_gateway"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function copyCardForCustomer($id, $params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("customers",$id,"copy_card"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function deleteCardForCustomer($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("customers",$id,"delete_card"), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+ }
+
+?>

+ 57 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/Comment.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace ChargeBee\ChargeBee\Models;
+
+use ChargeBee\ChargeBee\Model;
+use ChargeBee\ChargeBee\Request;
+use ChargeBee\ChargeBee\Util;
+
+class Comment extends Model
+{
+
+  protected $allowed = [
+    'id',
+    'entityType',
+    'addedBy',
+    'notes',
+    'createdAt',
+    'type',
+    'entityId',
+  ];
+
+
+
+  # OPERATIONS
+  #-----------
+
+  public static function create($params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("comments"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function retrieve($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::GET, Util::encodeURIPath("comments",$id), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function all($params = array(), $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::sendListRequest(Request::GET, Util::encodeURIPath("comments"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function delete($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("comments",$id,"delete"), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+ }
+
+?>

+ 31 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/Configuration.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace ChargeBee\ChargeBee\Models;
+
+use ChargeBee\ChargeBee\Model;
+use ChargeBee\ChargeBee\Request;
+use ChargeBee\ChargeBee\Util;
+
+class Configuration extends Model
+{
+
+  protected $allowed = [
+    'domain',
+    'productCatalogVersion',
+  ];
+
+
+
+  # OPERATIONS
+  #-----------
+
+  public static function all($env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::sendListRequest(Request::GET, Util::encodeURIPath("configurations"), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+ }
+
+?>

+ 131 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/Coupon.php

@@ -0,0 +1,131 @@
+<?php
+
+namespace ChargeBee\ChargeBee\Models;
+
+use ChargeBee\ChargeBee\Model;
+use ChargeBee\ChargeBee\Request;
+use ChargeBee\ChargeBee\Util;
+
+class Coupon extends Model
+{
+
+  protected $allowed = [
+    'id',
+    'name',
+    'invoiceName',
+    'discountType',
+    'discountPercentage',
+    'discountAmount',
+    'discountQuantity',
+    'currencyCode',
+    'durationType',
+    'durationMonth',
+    'validFrom',
+    'validTill',
+    'maxRedemptions',
+    'status',
+    'applyDiscountOn',
+    'applyOn',
+    'planConstraint',
+    'addonConstraint',
+    'createdAt',
+    'archivedAt',
+    'resourceVersion',
+    'updatedAt',
+    'includedInMrr',
+    'period',
+    'periodUnit',
+    'planIds',
+    'addonIds',
+    'itemConstraints',
+    'itemConstraintCriteria',
+    'redemptions',
+    'invoiceNotes',
+    'metaData',
+    'couponConstraints',
+    'deleted',
+  ];
+
+
+
+  # OPERATIONS
+  #-----------
+
+  public static function create($params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+        "metaData" => 0,
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("coupons"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function createForItems($params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+        "metaData" => 0,
+        "itemPriceIds" => 1,
+        "itemFamilyIds" => 1,
+        "currencies" => 1,
+        "itemPricePeriods" => 1,
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("coupons","create_for_items"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function updateForItems($id, $params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+        "metaData" => 0,
+        "itemPriceIds" => 1,
+        "itemFamilyIds" => 1,
+        "currencies" => 1,
+        "itemPricePeriods" => 1,
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("coupons",$id,"update_for_items"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function all($params = array(), $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::sendListRequest(Request::GET, Util::encodeURIPath("coupons"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function retrieve($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::GET, Util::encodeURIPath("coupons",$id), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function update($id, $params = array(), $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+        "metaData" => 0,
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("coupons",$id), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function delete($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("coupons",$id,"delete"), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function copy($params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("coupons","copy"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function unarchive($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("coupons",$id,"unarchive"), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+ }
+
+?>

+ 55 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/CouponCode.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace ChargeBee\ChargeBee\Models;
+
+use ChargeBee\ChargeBee\Model;
+use ChargeBee\ChargeBee\Request;
+use ChargeBee\ChargeBee\Util;
+
+class CouponCode extends Model
+{
+
+  protected $allowed = [
+    'code',
+    'status',
+    'couponId',
+    'couponSetId',
+    'couponSetName',
+  ];
+
+
+
+  # OPERATIONS
+  #-----------
+
+  public static function create($params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("coupon_codes"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function retrieve($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::GET, Util::encodeURIPath("coupon_codes",$id), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function all($params = array(), $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::sendListRequest(Request::GET, Util::encodeURIPath("coupon_codes"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function archive($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("coupon_codes",$id,"archive"), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+ }
+
+?>

+ 80 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/CouponSet.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace ChargeBee\ChargeBee\Models;
+
+use ChargeBee\ChargeBee\Model;
+use ChargeBee\ChargeBee\Request;
+use ChargeBee\ChargeBee\Util;
+
+class CouponSet extends Model
+{
+
+  protected $allowed = [
+    'id',
+    'couponId',
+    'name',
+    'totalCount',
+    'redeemedCount',
+    'archivedCount',
+    'metaData',
+  ];
+
+
+
+  # OPERATIONS
+  #-----------
+
+  public static function create($params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+        "metaData" => 0,
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("coupon_sets"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function addCouponCodes($id, $params = array(), $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("coupon_sets",$id,"add_coupon_codes"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function all($params = array(), $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::sendListRequest(Request::GET, Util::encodeURIPath("coupon_sets"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function retrieve($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::GET, Util::encodeURIPath("coupon_sets",$id), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function update($id, $params = array(), $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+        "metaData" => 0,
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("coupon_sets",$id,"update"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function delete($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("coupon_sets",$id,"delete"), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function deleteUnusedCouponCodes($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("coupon_sets",$id,"delete_unused_coupon_codes"), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+ }
+
+?>

+ 166 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/CreditNote.php

@@ -0,0 +1,166 @@
+<?php
+
+namespace ChargeBee\ChargeBee\Models;
+
+use ChargeBee\ChargeBee\Model;
+use ChargeBee\ChargeBee\Request;
+use ChargeBee\ChargeBee\Util;
+
+class CreditNote extends Model
+{
+
+  protected $allowed = [
+    'id',
+    'customerId',
+    'subscriptionId',
+    'referenceInvoiceId',
+    'type',
+    'reasonCode',
+    'status',
+    'vatNumber',
+    'date',
+    'priceType',
+    'currencyCode',
+    'total',
+    'amountAllocated',
+    'amountRefunded',
+    'amountAvailable',
+    'refundedAt',
+    'voidedAt',
+    'generatedAt',
+    'resourceVersion',
+    'updatedAt',
+    'channel',
+    'einvoice',
+    'subTotal',
+    'subTotalInLocalCurrency',
+    'totalInLocalCurrency',
+    'localCurrencyCode',
+    'roundOffAmount',
+    'fractionalCorrection',
+    'lineItems',
+    'discounts',
+    'lineItemDiscounts',
+    'lineItemTiers',
+    'taxes',
+    'lineItemTaxes',
+    'linkedRefunds',
+    'allocations',
+    'deleted',
+    'taxCategory',
+    'localCurrencyExchangeRate',
+    'createReasonCode',
+    'vatNumberPrefix',
+    'businessEntityId',
+    'shippingAddress',
+    'billingAddress',
+    'siteDetailsAtCreation',
+    'taxOrigin',
+  ];
+
+
+
+  # OPERATIONS
+  #-----------
+
+  public static function create($params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("credit_notes"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function retrieve($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::GET, Util::encodeURIPath("credit_notes",$id), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function pdf($id, $params = array(), $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("credit_notes",$id,"pdf"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function downloadEinvoice($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::GET, Util::encodeURIPath("credit_notes",$id,"download_einvoice"), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function refund($id, $params = array(), $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("credit_notes",$id,"refund"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function recordRefund($id, $params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("credit_notes",$id,"record_refund"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function voidCreditNote($id, $params = array(), $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("credit_notes",$id,"void"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function all($params = array(), $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::sendListRequest(Request::GET, Util::encodeURIPath("credit_notes"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function creditNotesForCustomer($id, $params = array(), $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::GET, Util::encodeURIPath("customers",$id,"credit_notes"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function delete($id, $params = array(), $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("credit_notes",$id,"delete"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function removeTaxWithheldRefund($id, $params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("credit_notes",$id,"remove_tax_withheld_refund"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function resendEinvoice($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("credit_notes",$id,"resend_einvoice"), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function sendEinvoice($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("credit_notes",$id,"send_einvoice"), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function importCreditNote($params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("credit_notes","import_credit_note"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+ }
+
+?>

+ 20 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/CreditNoteAllocation.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace ChargeBee\ChargeBee\Models;
+
+use ChargeBee\ChargeBee\Model;
+
+class CreditNoteAllocation extends Model
+{
+  protected $allowed = [
+    'invoiceId',
+    'allocatedAmount',
+    'allocatedAt',
+    'invoiceDate',
+    'invoiceStatus',
+    'taxApplication',
+  ];
+
+}
+
+?>

+ 20 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/CreditNoteDiscount.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace ChargeBee\ChargeBee\Models;
+
+use ChargeBee\ChargeBee\Model;
+
+class CreditNoteDiscount extends Model
+{
+  protected $allowed = [
+    'amount',
+    'description',
+    'entityType',
+    'discountType',
+    'entityId',
+    'couponSetCode',
+  ];
+
+}
+
+?>

+ 20 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/CreditNoteEstimateDiscount.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace ChargeBee\ChargeBee\Models;
+
+use ChargeBee\ChargeBee\Model;
+
+class CreditNoteEstimateDiscount extends Model
+{
+  protected $allowed = [
+    'amount',
+    'description',
+    'entityType',
+    'discountType',
+    'entityId',
+    'couponSetCode',
+  ];
+
+}
+
+?>

+ 70 - 0
upgrade/files/14.0.0.RC4-14.0.0.RC5/files/plugins/chargebee/chargebee-php/lib/ChargeBee/Models/Currency.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace ChargeBee\ChargeBee\Models;
+
+use ChargeBee\ChargeBee\Model;
+use ChargeBee\ChargeBee\Request;
+use ChargeBee\ChargeBee\Util;
+
+class Currency extends Model
+{
+
+  protected $allowed = [
+    'id',
+    'enabled',
+    'forexType',
+    'currencyCode',
+    'isBaseCurrency',
+    'manualExchangeRate',
+  ];
+
+
+
+  # OPERATIONS
+  #-----------
+
+  public static function all($env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::sendListRequest(Request::GET, Util::encodeURIPath("currencies","list"), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function retrieve($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::GET, Util::encodeURIPath("currencies",$id), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function create($params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("currencies"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function update($id, $params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("currencies",$id), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function addSchedule($id, $params, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("currencies",$id,"add_schedule"), $params, $env, $headers, null, false, $jsonKeys);
+  }
+
+  public static function removeSchedule($id, $env = null, $headers = array())
+  {
+    $jsonKeys = array(
+    );
+    return Request::send(Request::POST, Util::encodeURIPath("currencies",$id,"remove_schedule"), array(), $env, $headers, null, false, $jsonKeys);
+  }
+
+ }
+
+?>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio