diff --git a/config/vanilla/bootstrap.before.php b/config/vanilla/bootstrap.before.php index 55bbe6e..836b151 100644 --- a/config/vanilla/bootstrap.before.php +++ b/config/vanilla/bootstrap.before.php @@ -374,8 +374,7 @@ function watchButton($categoryID) { * @return bool return true if user has a permission */ function checkGroupPermission($userID,$groupID, $categoryID = null , $permissionCategoryID = null , $permission = null, $fullMatch = true) { - $groupModel = new GroupModel(); - return $groupModel->checkPermission($userID,$groupID, $categoryID,$permissionCategoryID , $permission, $fullMatch); + return GroupModel::checkPermission($userID,$groupID, $categoryID,$permissionCategoryID , $permission, $fullMatch); } } diff --git a/vanilla/applications/vanilla/models/class.categorymodel.php b/vanilla/applications/vanilla/models/class.categorymodel.php index e376b1b..3f7ea33 100644 --- a/vanilla/applications/vanilla/models/class.categorymodel.php +++ b/vanilla/applications/vanilla/models/class.categorymodel.php @@ -194,6 +194,13 @@ private static function loadAllCategories() { * @param bool|null $addUserCategory */ private function calculateUser(array &$category, $addUserCategory = null) { + $isCalculated = val('UserCalculated', $category, false); + if ($isCalculated) { + // Don't recalculate categories that have already been calculated. + return; + } + $category['UserCalculated'] = true; + // Kludge to make sure that the url is absolute when reaching the user's screen (or API). $category['Url'] = self::categoryUrl($category, '', true); @@ -611,7 +618,8 @@ private static function calculate(array &$category) { $category['PhotoUrl'] = ''; } - self::calculateDisplayAs($category); + // FIX-381: all categories are set with a value, this is used in Vanilla 2.X + // self::calculateDisplayAs($category); if (!($category['CssClass'] ?? false)) { $category['CssClass'] = 'Category-'.$category['UrlCode']; @@ -673,8 +681,14 @@ private static function calculateData(&$data) { self::calculate($category); } - $keys = array_reverse(array_keys($data)); - foreach ($keys as $key) { + //FIX: https://github.com/topcoder-platform/forums/issues/381 + // array_reverse and array_unshift - O(n) complexity + $notreversedKeys = array_keys($data); + $index = count($notreversedKeys) -1; + + while ($index >= 0) { + // key is reversed + $key = $notreversedKeys[$index]; $cat = $data[$key]; $parentID = $cat['ParentCategoryID']; @@ -688,8 +702,10 @@ private static function calculateData(&$data) { if (empty($data[$parentID]['ChildIDs'])) { $data[$parentID]['ChildIDs'] = []; } - array_unshift($data[$parentID]['ChildIDs'], $key); + + $data[$parentID]['ChildIDs'] = array_merge([$key], $data[$parentID]['ChildIDs']); } + $index--; } } @@ -1338,8 +1354,10 @@ private function gatherLastIDs($categoryTree, &$result = null) { * Given a discussion, update its category's last post info and counts. * * @param int|array|stdClass $discussion The discussion ID or discussion. + * @param null $cacheFields This param was added for particular issue + * check details https://github.com/topcoder-platform/forums/issues/381 */ - public function incrementLastDiscussion($discussion) { + public function incrementLastDiscussion($discussion, &$cacheFields = null) { // Lookup the discussion record, if necessary. We need at least a discussion to continue. if (filter_var($discussion, FILTER_VALIDATE_INT) !== false) { $discussion = DiscussionModel::instance()->getID($discussion); @@ -1358,20 +1376,29 @@ public function incrementLastDiscussion($discussion) { $countDiscussions = val('CountDiscussions', $category, 0); $countDiscussions++; - // setField will update these values in the DB, as well as the cache. - self::instance()->setField($categoryID, [ - 'CountDiscussions' => $countDiscussions, - 'LastCategoryID' => $categoryID - ]); + if(is_array($cacheFields)) { + $cacheFields[$categoryID]['CountDiscussions'] = $countDiscussions; + $cacheFields[$categoryID]['LastCategoryID'] = $categoryID; + self::instance()->setField($categoryID, [ + 'CountDiscussions' => $countDiscussions, + 'LastCategoryID' => $categoryID + ], false, false); + }else { + // setField will update these values in the DB, as well as the cache. + self::instance()->setField($categoryID, [ + 'CountDiscussions' => $countDiscussions, + 'LastCategoryID' => $categoryID + ]); + } // Update the cached last post info with whatever we have. - self::updateLastPost($discussion); + self::updateLastPost($discussion, null, $cacheFields); // Update the aggregate discussion count for this category and all its parents. - self::incrementAggregateCount($categoryID, self::AGGREGATE_DISCUSSION); + self::incrementAggregateCount($categoryID, self::AGGREGATE_DISCUSSION,1,false, $cacheFields); // Set the new LastCategoryID. - self::setAsLastCategory($categoryID); + self::setAsLastCategory($categoryID, $cacheFields); } /** @@ -1420,31 +1447,59 @@ public function incrementLastComment($comment) { } // setField will update these values in the DB, as well as the cache. - self::instance()->setField($categoryID, [ - 'CountComments' => $countComments, - 'LastCommentID' => $commentID, - 'LastDiscussionID' => $discussionID, - 'LastDateInserted' => val('DateInserted', $comment) - ]); + $categoryFields = self::instance()->setField($categoryID, [ + 'CountComments' => $countComments, + 'LastCommentID' => $commentID, + 'LastDiscussionID' => $discussionID, + 'LastDateInserted' => val('DateInserted', $comment) + ], false, false); + + // FIX: https://github.com/topcoder-platform/forums/issues/381 + // Add all updated fields in cacheFields and update cache at the end of the method + $cacheFields = array(); + foreach($categoryFields as $key =>$value) { + $cacheFields[$categoryID][$key] = $value; + } + + $categories = self::instance()->collection->getAncestors($categoryID, true); + foreach ($categories as $row) { + $currentCategoryID = val('CategoryID', $row); + $LastDiscussionCommentsUserID = val('UpdateUserID', $comment) ? val('UpdateUserID', $comment): val('InsertUserID', $comment); + $LastDiscussionCommentsDiscussionID = val('DiscussionID', $comment); + $LastDiscussionCommentsDate = val('UpdateUserID', $comment)?val('DateUpdated', $comment): val('DateInserted', $comment); + + $updatedColumns = ['LastDiscussionCommentsUserID' => $LastDiscussionCommentsUserID, + 'LastDiscussionCommentsDiscussionID' => $LastDiscussionCommentsDiscussionID, + 'LastDiscussionCommentsDate' => $LastDiscussionCommentsDate]; + $categoryFields = self::instance()->setField($currentCategoryID, $updatedColumns, false, false); + foreach($categoryFields as $key =>$value) { + $cacheFields[$currentCategoryID][$key] = $value; + } + } // Update the cached last post info with whatever we have. - self::updateLastPost($discussion, $comment); + self::updateLastPost($discussion, $comment, $cacheFields ); // Update the aggregate comment count for this category and all its parents. - self::incrementAggregateCount($categoryID, self::AGGREGATE_COMMENT); + self::incrementAggregateCount($categoryID, self::AGGREGATE_COMMENT, 1, false, $cacheFields); // Set the new LastCategoryID. - self::setAsLastCategory($categoryID); + self::setAsLastCategory($categoryID, $cacheFields); + + // Update cache for a category and its ancestors + self::setCache($cacheFields, false); } /** * Update the latest post info for a category and its ancestors. * * @param int|array|object $discussion - * @param int|array|object $comment + * @param null $comment + * @param null $cacheFields This param was added for particular issue + * check details https://github.com/topcoder-platform/forums/issues/381 */ - public static function updateLastPost($discussion, $comment = null) { - // Make sure we at least have a discussion to work with. + public static function updateLastPost($discussion, $comment = null, &$cacheFields = null) { + // Make sure we at least have a discussion to work with. if (is_numeric($discussion)) { $discussion = DiscussionModel::instance()->getID($discussion); } @@ -1464,10 +1519,18 @@ public static function updateLastPost($discussion, $comment = null) { $db = static::postDBFields($discussion, $comment); $categories = self::instance()->collection->getAncestors($categoryID, true); + foreach ($categories as $row) { $currentCategoryID = val('CategoryID', $row); - self::instance()->setField($currentCategoryID, $db); - CategoryModel::setCache($currentCategoryID, $cache); + if(is_array($cacheFields)) { + foreach ($cache as $key => $value) { + $cacheFields[$currentCategoryID][$key] = $value; + } + self::instance()->setField($currentCategoryID, $db, false, false); + } else { + self::instance()->setField($currentCategoryID, $db); + CategoryModel::setCache($currentCategoryID, $cache); + } } } @@ -1475,9 +1538,10 @@ public static function updateLastPost($discussion, $comment = null) { * Update the latest post info for a category and its ancestors. * * @param int|array|object $discussion - * @param int|array|object $comment + * @param null $cacheFields This param was added for particular issue + * check details https://github.com/topcoder-platform/forums/issues/381 */ - public static function updateModifiedDiscussion($discussion) { + public static function updateModifiedDiscussion($discussion, & $cacheFields = null) { // Make sure we at least have a discussion to work with. if (is_numeric($discussion)) { $discussion = DiscussionModel::instance()->getID($discussion); @@ -1494,8 +1558,15 @@ public static function updateModifiedDiscussion($discussion) { $categories = self::instance()->collection->getAncestors($categoryID, true); foreach ($categories as $row) { $currentCategoryID = val('CategoryID', $row); - self::instance()->setField($currentCategoryID, $db); - CategoryModel::setCache($currentCategoryID, $cache); + if($cacheFields) { + self::instance()->setField($currentCategoryID, $db, false, false); + foreach ($cache as $key => $value) { + $cacheFields[$currentCategoryID][$key] = $value; + } + } else { + self::instance()->setField($currentCategoryID, $db); + CategoryModel::setCache($currentCategoryID, $cache); + } } } @@ -1644,7 +1715,7 @@ private static function modifiedCommentCacheFields(array $comment) if ($comment) { if(val('UpdateUserID', $comment)) { $result['LastDiscussionCommentsUserID'] = val('UpdateUserID', $comment); - $result['LastDiscussionCommentsDiscussionID'] = val('DiscussionID', $$comment); + $result['LastDiscussionCommentsDiscussionID'] = val('DiscussionID', $comment); $result['LastDiscussionCommentsDate'] = val('DateUpdated', $comment); } else { $result['LastDiscussionCommentsUserID'] = val('InsertUserID', $comment); @@ -1725,6 +1796,7 @@ private static function postDBFields($discussion, $comment = null) { public function joinRecent(&$categoryTree) { // Gather all of the IDs from the posts. $this->gatherLastIDs($categoryTree, $ids); + // TODO: optimize the nex lines $discussionIDs = array_unique(array_column($ids, 'DiscussionID')); $commentIDs = array_filter(array_unique(array_column($ids, 'CommentID'))); $userIDs = array_filter(array_unique(array_column($ids, 'UserID'))); @@ -1752,6 +1824,7 @@ public function joinRecent(&$categoryTree) { } } + //TODO: bug ? $userIDs[] = ''; // Just gather the users into the local cache. Gdn::userModel()->getIDs($userIDs); @@ -3320,11 +3393,23 @@ public function saveUserTree($categoryID, $set) { * * @since 2.0.18 * @access public - * @param int|bool $iD + * @param int|bool|array $iD Supports updating cache for several categories. + * Use [CategoryID => Data] to set cache for several categories + * This fix was added for particular issue + * check details https://github.com/topcoder-platform/forums/issues/381 * @param array|bool $data */ public static function setCache($iD = false, $data = false) { - self::instance()->collection->refreshCache((int)$iD); + $ids = false; + if(is_array($iD)) { // categoryID => dataFields + $ids = $iD; + } else if(is_numeric($iD)){ + $ids[$iD] = $data; + } + + foreach ($ids as $i => $data) { + self::instance()->collection->refreshCache((int)$i); + } $categories = Gdn::cache()->get(self::CACHE_KEY); self::$Categories = null; @@ -3340,20 +3425,29 @@ public static function setCache($iD = false, $data = false) { } $categories = $categories['categories']; - // Check for category in list, otherwise remove key if not found - if (!array_key_exists($iD, $categories)) { - Gdn::cache()->remove(self::CACHE_KEY); - return; + foreach ($ids as $i => $data) { + // Check for category in list, otherwise remove key if not found + if (!array_key_exists($i, $categories)) { + Gdn::cache()->remove(self::CACHE_KEY); + unset($ids[$i]); + } } - $category = $categories[$iD]; - $category = array_merge($category, $data); - $categories[$iD] = $category; - + if(count($ids) == 0) { + return; + } + foreach ($ids as $i => $data) { + $category = $categories[$i]; + $category = array_merge($category, $data); + $categories[$i] = $category; + } // Update memcache entry self::$Categories = $categories; unset($categories); - self::buildCache($iD); + + foreach ($ids as $i => $data) { + self::buildCache($i); + } self::joinUserData(self::$Categories, true); } @@ -3364,9 +3458,11 @@ public static function setCache($iD = false, $data = false) { * @param int $iD * @param array|string $property * @param bool|false $value + * @param bool $cache This param was added for particular issue + * check details https://github.com/topcoder-platform/forums/issues/381 * @return array|string */ - public function setField($iD, $property, $value = false) { + public function setField($iD, $property, $value = false, $cache = true) { if (!is_array($property)) { $property = [$property => $value]; } @@ -3378,7 +3474,9 @@ public function setField($iD, $property, $value = false) { $this->SQL->put($this->Name, $property, ['CategoryID' => $iD]); // Set the cache. - self::setCache($iD, $property); + if($cache) { + self::setCache($iD, $property); + } return $property; } @@ -3468,8 +3566,10 @@ public function refreshAggregateRecentPost($categoryID, $updateAncestors = false * * * @param $categoryID + * @param null $cacheFields This param was added for particular issue + * check details https://github.com/topcoder-platform/forums/issues/381 */ - public function setRecentPost($categoryID) { + public function setRecentPost($categoryID, & $cacheFields = null) { $row = $this->SQL->getWhere('Discussion', ['CategoryID' => $categoryID], 'DateLastComment', 'desc', 1)->firstRow(DATASET_TYPE_ARRAY); $fields = ['LastCommentID' => null, 'LastDiscussionID' => null]; @@ -3478,8 +3578,20 @@ public function setRecentPost($categoryID) { $fields['LastCommentID'] = $row['LastCommentID']; $fields['LastDiscussionID'] = $row['DiscussionID']; } - $this->setField($categoryID, $fields); - self::setCache($categoryID, ['LastTitle' => null, 'LastUserID' => null, 'LastDateInserted' => null, 'LastUrl' => null]); + if(is_array($cacheFields)) { + foreach($fields as $key =>$value) { + $cacheFields[$categoryID][$key] = $value; + } + $cacheFields[$categoryID]['LastTitle'] = null; + $cacheFields[$categoryID]['LastUserID'] = null; + $cacheFields[$categoryID]['LastDateInserted'] = null; + $cacheFields[$categoryID]['LastUrl'] = null; + + $this->setField($categoryID, $fields, false, false); + } else { + $this->setField($categoryID, $fields); + self::setCache($categoryID, ['LastTitle' => null, 'LastUserID' => null, 'LastDateInserted' => null, 'LastUrl' => null]); + } } /** @@ -3713,8 +3825,11 @@ public static function flattenTree($categories) { * check details https://github.com/vanilla/vanilla/issues/7105 * and https://github.com/vanilla/vanilla/pull/7843 * please avoid of using it. + * @param $cacheFields This param was added for particular issue + * check details https://github.com/topcoder-platform/forums/issues/381 + * @throws Exception */ - private static function adjustAggregateCounts($categoryID, $type, $offset, bool $cache = true) { + private static function adjustAggregateCounts($categoryID, $type, $offset, bool $cache = true, &$cacheFields) { $offset = intval($offset); if (empty($categoryID)) { @@ -3743,6 +3858,18 @@ private static function adjustAggregateCounts($categoryID, $type, $offset, bool } } + if(is_array($cacheFields)){ + $categoriesToUpdate = self::instance()->getWhere(['CategoryID' => $updatedCategories]); + foreach ($categoriesToUpdate as $current) { + $currentID = val('CategoryID', $current); + $countAllDiscussions = val('CountAllDiscussions', $current); + $countAllComments = val('CountAllComments', $current); + $cacheFields[$currentID]['CountAllDiscussions'] = $countAllDiscussions; + $cacheFields[$currentID]['CountAllComments'] = $countAllComments; + + } + } + // Update the cache. if ($cache) { $categoriesToUpdate = self::instance()->getWhere(['CategoryID' => $updatedCategories]); @@ -3768,11 +3895,14 @@ private static function adjustAggregateCounts($categoryID, $type, $offset, bool * check details https://github.com/vanilla/vanilla/issues/7105 * and https://github.com/vanilla/vanilla/pull/7843 * please avoid of using it. + * @param null $fields This param was added for particular issue + * check details https://github.com/topcoder-platform/forums/issues/381 + * @throws Exception */ - public static function incrementAggregateCount($categoryID, $type, $offset = 1, bool $cache = true) { + public static function incrementAggregateCount($categoryID, $type, $offset = 1, bool $cache = true, &$fields = null) { // Make sure we're dealing with a positive offset. $offset = abs($offset); - self::adjustAggregateCounts($categoryID, $type, $offset, $cache); + self::adjustAggregateCounts($categoryID, $type, $offset, $cache,$fields); } /** @@ -3785,11 +3915,14 @@ public static function incrementAggregateCount($categoryID, $type, $offset = 1, * check details https://github.com/vanilla/vanilla/issues/7105 * and https://github.com/vanilla/vanilla/pull/7843 * please avoid of using it. + * @param bool $fields This param was added for particular issue + * check details https://github.com/topcoder-platform/forums/issues/381 + * @throws Exception */ - public static function decrementAggregateCount($categoryID, $type, $offset = 1, bool $cache = true) { + public static function decrementAggregateCount($categoryID, $type, $offset = 1, bool $cache = true, & $fields = false) { // Make sure we're dealing with a negative offset. $offset = (-1 * abs($offset)); - self::adjustAggregateCounts($categoryID, $type, $offset, $cache); + self::adjustAggregateCounts($categoryID, $type, $offset, $cache,$fields); } /** @@ -3905,13 +4038,20 @@ public function searchByName($name, $expandParent = false, $limit = null, $offse * Update a category and its parents' LastCategoryID with the specified category's ID. * * @param int $categoryID A valid category ID. + * @param null $cacheFields This param was added for particular issue + * check details https://github.com/topcoder-platform/forums/issues/381 */ - public static function setAsLastCategory($categoryID) { + public static function setAsLastCategory($categoryID, &$cacheFields = null) { $categories = self::instance()->collection->getAncestors($categoryID, true); foreach ($categories as $current) { $targetID = val('CategoryID', $current); - self::instance()->setField($targetID, ['LastCategoryID' => $categoryID]); + if(is_array($cacheFields)) { + $cacheFields[$targetID]['LastCategoryID'] = $categoryID; + self::instance()->setField($targetID, ['LastCategoryID' => $categoryID], false, false); + }else { + self::instance()->setField($targetID, ['LastCategoryID' => $categoryID]); + } } } } diff --git a/vanilla/applications/vanilla/models/class.commentmodel.php b/vanilla/applications/vanilla/models/class.commentmodel.php index 95d9f13..be4410c 100644 --- a/vanilla/applications/vanilla/models/class.commentmodel.php +++ b/vanilla/applications/vanilla/models/class.commentmodel.php @@ -1365,8 +1365,6 @@ public function save2($CommentID, $Insert, $CheckExisting = true, $IncUser = fal ['DiscussionID' => $DiscussionID, 'UserID' => val('InsertUserID', $Fields)] ); - // Update cached modified column info for a category. - CategoryModel::updateModifiedComment($Fields); if ($Insert) { // UPDATE COUNT AND LAST COMMENT ON CATEGORY TABLE @@ -1380,6 +1378,8 @@ public function save2($CommentID, $Insert, $CheckExisting = true, $IncUser = fal $Discussion ? (array)$Discussion : null ); } + } else { + CategoryModel::updateModifiedComment($Fields); } } diff --git a/vanilla/applications/vanilla/models/class.discussionmodel.php b/vanilla/applications/vanilla/models/class.discussionmodel.php index 002030a..a953cb9 100644 --- a/vanilla/applications/vanilla/models/class.discussionmodel.php +++ b/vanilla/applications/vanilla/models/class.discussionmodel.php @@ -2124,6 +2124,10 @@ public function save($formPostValues, $settings = false) { } if (count($validationResults) == 0) { + + // FIX: https://github.com/topcoder-platform/forums/issues/381 + $cacheFields = array(); + // Backward compatible check for flood control if (!val('SpamCheck', $this, true)) { deprecated('DiscussionModel->SpamCheck attribute', 'FloodControlTrait->setFloodControlEnabled()'); @@ -2226,8 +2230,8 @@ public function save($formPostValues, $settings = false) { $discussionID = $this->SQL->insert($this->Name, $fields); $fields['DiscussionID'] = $discussionID; - // Update cached last post info for a category. - CategoryModel::updateLastPost($fields); + // Update last post info for a category in DB, then in cache + CategoryModel::updateLastPost($fields, null, $cacheFields); // Clear the cache if necessary. if (val('Announce', $fields)) { @@ -2262,17 +2266,22 @@ public function save($formPostValues, $settings = false) { // Update discussion counter for affected categories. if ($insert || $storedCategoryID) { - CategoryModel::instance()->incrementLastDiscussion($discussion); + CategoryModel::instance()->incrementLastDiscussion($discussion, $cacheFields); } - if ($storedCategoryID) { - $this->updateDiscussionCount($storedCategoryID); + if ($storedCategoryID) { + $this->updateDiscussionCount($storedCategoryID, false, $cacheFields); } + // Update cached modified discussion info for a category. - CategoryModel::updateModifiedDiscussion($discussion); + CategoryModel::updateModifiedDiscussion($discussion, $cacheFields); + + // Update cache + CategoryModel::setCache($cacheFields, false); - $this->calculateMediaAttachments($discussionID, !$insert); + // Don't use MediaAttahmenent for discussions + // $this->calculateMediaAttachments($discussionID, !$insert); // Fire an event that the discussion was saved. $this->EventArguments['FormPostValues'] = $formPostValues; @@ -2281,8 +2290,7 @@ public function save($formPostValues, $settings = false) { $this->EventArguments['SendNewDiscussionNotification'] = $sendNewDiscussionNotification; $this->fireEvent('AfterSaveDiscussion'); - - //FIX: https://github.com/topcoder-platform/forums/issues/213 + // FIX: https://github.com/topcoder-platform/forums/issues/213 // If the plugin is enabled then send notifications after updating MediaTables with discussionID if ($sendNewDiscussionNotification === true) { if(!c('EnabledPlugins.editor', false)) { @@ -2498,8 +2506,10 @@ private function recordAdvancedNotications(ActivityModel $activityModel, array $ * * @param int $categoryID Unique ID of category we are updating. * @param array|false $discussion The discussion to update the count for or **false** for all of them. + * @param null $cacheFields This param was added for particular issue + * check details https://github.com/topcoder-platform/forums/issues/381 */ - public function updateDiscussionCount($categoryID, $discussion = false) { + public function updateDiscussionCount($categoryID, $discussion = false, &$cacheFields = null) { $discussionID = val('DiscussionID', $discussion, false); if (strcasecmp($categoryID, 'All') == 0) { $exclude = (bool)Gdn::config('Vanilla.Archive.Exclude'); @@ -2557,8 +2567,17 @@ public function updateDiscussionCount($categoryID, $discussion = false) { } $categoryModel = new CategoryModel(); - $categoryModel->setField($categoryID, $cacheAmendment); - $categoryModel->setRecentPost($categoryID); + if(is_array($cacheFields)) { + //Update cache later + foreach($cacheAmendment as $key =>$value) { + $cacheFields[$categoryID][$key] = $value; + } + $categoryModel->setField($categoryID, $cacheAmendment, false, false); + $categoryModel->setRecentPost($categoryID, $cacheFields); + } else { + $categoryModel->setField($categoryID, $cacheAmendment); + $categoryModel->setRecentPost($categoryID); + } } } diff --git a/vanilla/library/core/class.controller.php b/vanilla/library/core/class.controller.php new file mode 100644 index 0000000..b79ccb0 --- /dev/null +++ b/vanilla/library/core/class.controller.php @@ -0,0 +1,2420 @@ + + * @author Todd Burry + * @author Tim Gunter + * @copyright 2009-2019 Vanilla Forums Inc. + * @license GPL-2.0-only + * @package Core + * @since 2.0 + * @abstract + */ + +use Vanilla\Models\ThemePreloadProvider; +use \Vanilla\Web\Asset\LegacyAssetModel; +use Vanilla\Web\HttpStrictTransportSecurityModel; +use Vanilla\Web\ContentSecurityPolicy\ContentSecurityPolicyModel; +use Vanilla\Web\ContentSecurityPolicy\Policy; +use Vanilla\Web\JsInterpop\ReduxActionPreloadTrait; + +/** + * Controller base class. + * + * A base class that all controllers can inherit for common properties and methods. + * + * @method void render($view = '', $controllerName = false, $applicationFolder = false, $assetName = 'Content') Render the controller's view. + */ +class Gdn_Controller extends Gdn_Pluggable { + use \Garden\MetaTrait, ReduxActionPreloadTrait; + + /** Seconds before reauthentication is required for protected operations. */ + const REAUTH_TIMEOUT = 1200; // 20 minutes + + /** @var string The name of the application that this controller can be found in. */ + public $Application; + + /** @var string The name of the application folder that this controller can be found in. */ + public $ApplicationFolder; + + /** + * @var array An associative array that contains content to be inserted into the + * master view. All assets are placed in this array before being passed to + * the master view. If an asset's key is not called by the master view, + * that asset will not be rendered. + */ + public $Assets; + + /** @var string */ + protected $_CanonicalUrl; + + /** + * @var string The name of the controller that holds the view (used by $this->FetchView + * when retrieving the view). Default value is $this->ClassName. + */ + public $ControllerName; + + /** + * @var string A CSS class to apply to the body tag of the page. Note: you can only + * assume that the master view will use this property (ie. a custom theme + * may not decide to implement this property). + */ + public $CssClass; + + /** @var array The data that a controller method has built up from models and other calculations. */ + public $Data = []; + + /** @var HeadModule The Head module that this controller should use to add CSS files. */ + public $Head; + + /** + * @var string The name of the master view that has been requested. Typically this is + * part of the master view's file name. ie. $this->MasterView.'.master.tpl' + */ + public $MasterView; + + /** @var object A Menu module for rendering the main menu on each page. */ + public $Menu; + + /** + * @var string An associative array of assets and what order their modules should be rendered in. + * You can set module sort orders in the config using Modules.ModuleSortContainer.AssetName. + * @example $Configuration['Modules']['Vanilla']['Panel'] = array('CategoryModule', 'NewDiscussionModule'); + */ + public $ModuleSortContainer; + + /** @var string The method that was requested before the dispatcher did any re-routing. */ + public $OriginalRequestMethod; + + /** + * @deprecated + * @var string The URL to redirect the user to by ajax'd forms after the form is successfully saved. + */ + public $RedirectUrl; + + /** + * @var string The URL to redirect the user to by ajax'd forms after the form is successfully saved. + */ + protected $redirectTo; + + /** @var string Fully resolved path to the application/controller/method. */ + public $ResolvedPath; + + /** @var array The arguments passed into the controller mapped to their proper argument names. */ + public $ReflectArgs; + + /** + * @var mixed This is typically an array of arguments passed after the controller + * name and controller method in the query string. Additional arguments are + * parsed out by the @@Dispatcher and sent to $this->RequestArgs as an + * array. If there are no additional arguments specified, this value will + * remain FALSE. + * ie. http://localhost/index.php?/controller_name/controller_method/arg1/arg2/arg3 + * translates to: array('arg1', 'arg2', 'arg3'); + */ + public $RequestArgs; + + /** + * @var string The method that has been requested. The request method is defined by the + * @@Dispatcher as the second parameter passed in the query string. In the + * following example it would be "controller_method" and it relates + * directly to the method that will be called in the controller. This value + * is also used as $this->View unless $this->View has already been + * hard-coded to be something else. + * ie. http://localhost/index.php?/controller_name/controller_method/ + */ + public $RequestMethod; + + /** @var Gdn_Request Reference to the Request object that spawned this controller. */ + public $Request; + + /** @var string The requested url to this controller. */ + public $SelfUrl; + + /** + * @var string The message to be displayed on the screen by ajax'd forms after the form + * is successfully saved. + * + * @deprecated since 2.0.18; $this->errorMessage() and $this->informMessage() + * are to be used going forward. + */ + public $StatusMessage; + + /** @var stringDefined by the dispatcher: SYNDICATION_RSS, SYNDICATION_ATOM, or SYNDICATION_NONE (default). */ + public $SyndicationMethod; + + /** + * @var string The name of the folder containing the views to be used by this + * controller. This value is retrieved from the $Configuration array when + * this class is instantiated. Any controller can then override the property + * before render if there is a need. + */ + public $Theme; + + /** @var array Specific options on the currently selected theme. */ + public $ThemeOptions; + + /** @var string Name of the view that has been requested. Typically part of the view's file name. ie. $this->View.'.php' */ + public $View; + + /** @var bool Indicate that the controller add the `defer` attribute to it's legacy scripts. */ + protected $useDeferredLegacyScripts; + + /** @var bool Disable this to disabled custom theming for the page. */ + protected $allowCustomTheming = true; + + /** @var array An array of CSS file names to search for in theme folders & include in the page. */ + protected $_CssFiles; + + /** + * @var array A collection of definitions that will be written to the screen in a hidden unordered list + * so that JavaScript has access to them (ie. for language translations, web root, etc). + */ + protected $_Definitions; + + /** + * @var string An enumerator indicating how the response should be delivered to the + * output buffer. Options are: + * DELIVERY_METHOD_XHTML: page contents are delivered as normal. + * DELIVERY_METHOD_JSON: page contents and extra information delivered as JSON. + * The default value is DELIVERY_METHOD_XHTML. + */ + protected $_DeliveryMethod; + + /** + * @var string An enumerator indicating what should be delivered to the screen. Options are: + * DELIVERY_TYPE_ALL: The master view and everything in the requested asset (DEFAULT). + * DELIVERY_TYPE_ASSET: Everything in the requested asset. + * DELIVERY_TYPE_VIEW: Only the requested view. + * DELIVERY_TYPE_BOOL: Deliver only the success status (or error) of the request + * DELIVERY_TYPE_NONE: Deliver nothing + */ + protected $_DeliveryType; + + /** @var string A string of html containing error messages to be displayed to the user. */ + protected $_ErrorMessages; + + /** @var bool Allows overriding 'FormSaved' property to send with DELIVERY_METHOD_JSON. */ + protected $_FormSaved; + + /** @var array An associative array of header values to be sent to the browser before the page is rendered. */ + protected $_Headers; + + /** @var array An array of internal methods that cannot be dispatched. */ + protected $internalMethods; + + /** @var array A collection of "inform" messages to be displayed to the user. */ + protected $_InformMessages; + + /** @var array An array of JS file names to search for in app folders & include in the page. */ + protected $_JsFiles; + + /** + * @var array If JSON is going to be delivered to the client (see the render method), + * this property will hold the values being sent. + */ + protected $_Json; + + /** @var array A collection of view locations that have already been found. Used to prevent re-finding views. */ + protected $_ViewLocations; + + /** @var string|null */ + protected $_PageName = null; + + /** + * + */ + public function __construct() { + $this->useDeferredLegacyScripts = \Vanilla\FeatureFlagHelper::featureEnabled('DeferredLegacyScripts'); + $this->Application = ''; + $this->ApplicationFolder = ''; + $this->Assets = []; + $this->CssClass = ''; + $this->Data = []; + $this->Head = Gdn::factory('Dummy'); + $this->internalMethods = [ + 'addasset', 'addbreadcrumb', 'addcssfile', 'adddefinition', 'addinternalmethod', 'addjsfile', 'addmodule', + 'allowjsonp', 'canonicalurl', 'clearcssfiles', 'clearjsfiles', 'contenttype', 'cssfiles', 'data', + 'definitionlist', 'deliverymethod', 'deliverytype', 'description', 'errormessages', 'fetchview', + 'fetchviewlocation', 'finalize', 'getasset', 'getimports', 'getjson', 'getstatusmessage', 'image', + 'informmessage', 'intitialize', 'isinternal', 'jsfiles', 'json', 'jsontarget', 'masterview', 'pagename', + 'permission', 'removecssfile', 'render', 'xrender', 'renderasset', 'renderdata', 'renderexception', + 'rendermaster', 'renderreact', 'sendheaders', 'setdata', 'setformsaved', 'setheader', 'setjson', + 'setlastmodified', 'statuscode', 'title' + ]; + $this->MasterView = ''; + $this->ModuleSortContainer = ''; + $this->OriginalRequestMethod = ''; + $this->RedirectUrl = ''; + $this->RequestMethod = ''; + $this->RequestArgs = false; + $this->Request = null; + $this->SelfUrl = ''; + $this->SyndicationMethod = SYNDICATION_NONE; + $this->Theme = theme(); + $this->ThemeOptions = Gdn::config('Garden.ThemeOptions', []); + $this->View = ''; + $this->_CssFiles = []; + $this->_JsFiles = []; + $this->_Definitions = []; + $this->_DeliveryMethod = DELIVERY_METHOD_XHTML; + $this->_DeliveryType = DELIVERY_TYPE_ALL; + $this->_FormSaved = ''; + $this->_Json = []; + $this->_Headers = [ + 'X-Garden-Version' => APPLICATION.' '.APPLICATION_VERSION, + 'Content-Type' => Gdn::config('Garden.ContentType', '').'; charset=utf-8' // PROPERLY ENCODE THE CONTENT +// 'Last-Modified' => gmdate('D, d M Y H:i:s') . ' GMT', // PREVENT PAGE CACHING: always modified (this can be overridden by specific controllers) + ]; + + if (Gdn::session()->isValid() || Gdn::request()->getMethod() !== 'GET') { + $this->_Headers = array_merge($this->_Headers, [ + 'Cache-Control' => \Vanilla\Web\CacheControlMiddleware::NO_CACHE, // PREVENT PAGE CACHING: HTTP/1.1 + ]); + } else { + $this->_Headers = array_merge($this->_Headers, [ + 'Cache-Control' => \Vanilla\Web\CacheControlMiddleware::PUBLIC_CACHE, + 'Vary' => \Vanilla\Web\CacheControlMiddleware::VARY_COOKIE, + ]); + } + + $hsts = Gdn::getContainer()->get('HstsModel'); + $this->_Headers[HttpStrictTransportSecurityModel::HSTS_HEADER] = $hsts->getHsts(); + + $cspModel = Gdn::factory(ContentSecurityPolicyModel::class); + $this->_Headers[ContentSecurityPolicyModel::CONTENT_SECURITY_POLICY] = $cspModel->getHeaderString(Policy::FRAME_ANCESTORS); + + + $this->_ErrorMessages = ''; + $this->_InformMessages = []; + $this->StatusMessage = ''; + + parent::__construct(); + $this->ControllerName = strtolower($this->ClassName); + + $currentTheme = Gdn::getContainer()->get(\Vanilla\AddonManager::class)->getTheme(); + if ($currentTheme instanceof \Vanilla\Addon) { + $this->addDefinition('currentThemePath', $currentTheme->getSubdir()); + } + } + + /** + * Add a breadcrumb to the list. + * + * @param string $name Translation code + * @param string $link Optional. Hyperlink this breadcrumb somewhere. + * @param string $position Optional. Where in the list to add it? 'front', 'back' + */ + public function addBreadcrumb($name, $link = null, $position = 'back') { + $breadcrumb = [ + 'Name' => t($name), + 'Url' => $link + ]; + + $breadcrumbs = $this->data('Breadcrumbs', []); + switch ($position) { + case 'back': + $breadcrumbs = array_merge($breadcrumbs, [$breadcrumb]); + break; + case 'front': + $breadcrumbs = array_merge([$breadcrumb], $breadcrumbs); + break; + } + $this->setData('Breadcrumbs', $breadcrumbs); + } + + /** + * Adds as asset (string) to the $this->Assets collection. + * + * The assets will later be added to the view if their $assetName is called by + * $this->renderAsset($assetName) within the view. + * + * @param string $assetContainer The name of the asset container to add $asset to. + * @param mixed $asset The asset to be rendered in the view. This can be one of: + * - string: The string will be rendered. + * - Gdn_IModule: Gdn_IModule::render() will be called. + * @param string $assetName The name of the asset being added. This can be + * used later to sort assets before rendering. + */ + public function addAsset($assetContainer, $asset, $assetName = '') { + if (is_object($assetName)) { + return false; + } elseif ($assetName == '') { + $this->Assets[$assetContainer][] = $asset; + } else { + if (isset($this->Assets[$assetContainer][$assetName])) { + if (!is_string($asset)) { + $asset = $asset->toString(); + } + $this->Assets[$assetContainer][$assetName] .= $asset; + } else { + $this->Assets[$assetContainer][$assetName] = $asset; + } + } + } + + /** + * Adds a CSS file to search for in the theme folder(s). + * + * @param string $fileName The CSS file to search for. + * @param string $appFolder The application folder that should contain the CSS file. Default is to + * use the application folder that this controller belongs to. + * - If you specify plugins/PluginName as $appFolder then you can contain a CSS file in a plugin's design folder. + */ + public function addCssFile($fileName, $appFolder = '', $options = null) { + $this->_CssFiles[] = ['FileName' => $fileName, 'AppFolder' => $appFolder, 'Options' => $options]; + } + + /** + * Adds a key-value pair to the definition collection for JavaScript. + * + * @param string $term + * @param string $definition + */ + public function addDefinition($term, $definition = null) { + if (!is_null($definition)) { + $this->_Definitions[$term] = $definition; + } + return val($term, $this->_Definitions); + } + + /** + * Add an method to the list of internal methods. + * + * @param string $methodName The name of the internal method to add. + */ + public function addInternalMethod($methodName) { + $this->internalMethods[] = strtolower($methodName); + } + + /** + * Mapping of how certain legacy javascript files have been split up. + * + * If you include the key, all of the files in it's value will be included as well. + */ + const SPLIT_JS_MAPPINGS = [ + 'global.js' => [ + 'flyouts.js', + ], + ]; + + /** + * Adds a JS file to search for in the application or global js folder(s). + * + * @param string $fileName The js file to search for. + * @param string $appFolder The application folder that should contain the JS file. Default is to use the application folder that this controller belongs to. + */ + public function addJsFile($fileName, $appFolder = '', $options = null) { + $jsInfo = ['FileName' => $fileName, 'AppFolder' => $appFolder, 'Options' => $options]; + + if (stringBeginsWith($appFolder, 'plugins/')) { + $name = stringBeginsWith($appFolder, 'plugins/', true, true); + $info = Gdn::pluginManager()->getPluginInfo($name, Gdn_PluginManager::ACCESS_PLUGINNAME); + if ($info) { + $jsInfo['Version'] = val('Version', $info); + } + } else { + $jsInfo['Version'] = APPLICATION_VERSION; + } + + $this->_JsFiles[] = $jsInfo; + + if ($appFolder === '' && array_key_exists($fileName, self::SPLIT_JS_MAPPINGS)) { + $items = self::SPLIT_JS_MAPPINGS[$fileName]; + foreach ($items as $item) { + $this->addJsFile($item, $appFolder, $options); + } + } + } + + /** + * Adds the specified module to the specified asset target. + * + * If no asset target is defined, it will use the asset target defined by the + * module's AssetTarget method. + * + * @param mixed $module A module or the name of a module to add to the page. + * @param string $assetTarget + */ + public function addModule($module, $assetTarget = '') { + $this->fireEvent('BeforeAddModule'); + $assetModule = $module; + + if (!is_object($assetModule)) { + if (property_exists($this, $module) && is_object($this->$module)) { + $assetModule = $this->$module; + } else { + $moduleClassExists = class_exists($module); + + if ($moduleClassExists) { + // Make sure that the class implements Gdn_IModule + $reflectionClass = new ReflectionClass($module); + if ($reflectionClass->implementsInterface("Gdn_IModule")) { + $assetModule = new $module($this); + } + } + } + } + + if (is_object($assetModule)) { + $assetTarget = ($assetTarget == '' ? $assetModule->assetTarget() : $assetTarget); + // echo '
adding: '.get_class($AssetModule).' ('.(property_exists($AssetModule, 'HtmlId') ? $AssetModule->HtmlId : '').') to '.$AssetTarget.'
'; + $this->addAsset($assetTarget, $assetModule, $assetModule->name()); + } + + $this->fireEvent('AfterAddModule'); + } + + /** + * + * + * @param null $value + * @return mixed|null + */ + public function allowJSONP($value = null) { + static $_Value; + + if (isset($value)) { + $_Value = $value; + } + + if (isset($_Value)) { + return $_Value; + } else { + return c('Garden.AllowJSONP'); + } + } + + /** + * + * + * @param null $value + * @return null|string + */ + public function canonicalUrl($value = null) { + if ($value === null) { + if ($this->_CanonicalUrl || $this->_CanonicalUrl === '') { + return $this->_CanonicalUrl; + } else { + $parts = []; + + $controller = strtolower(stringEndsWith($this->ControllerName, 'Controller', true, true)); + + if ($controller == 'settings') { + $parts[] = strtolower($this->ApplicationFolder); + } + + if ($controller != 'root') { + $parts[] = $controller; + } + + if (strcasecmp($this->RequestMethod, 'index') != 0) { + $parts[] = strtolower($this->RequestMethod); + } + + // The default canonical url is the fully-qualified url. + if (is_array($this->RequestArgs)) { + $parts = array_merge($parts, $this->RequestArgs); + } elseif (is_string($this->RequestArgs)) + $parts = trim($this->RequestArgs, '/'); + + $path = implode('/', $parts); + $result = url($path, true); + return $result; + } + } else { + $this->_CanonicalUrl = $value; + return $value; + } + } + + /** + * + */ + public function clearCssFiles() { + $this->_CssFiles = []; + } + + /** + * Clear all js files from the collection. + */ + public function clearJsFiles() { + $this->_JsFiles = []; + } + + /** + * + * + * @param $contentType + */ + public function contentType($contentType) { + $this->setHeader("Content-Type", $contentType); + } + + /** + * + * + * @return array + */ + public function cssFiles() { + return $this->_CssFiles; + } + + /** + * Get a value out of the controller's data array. + * + * @param string $path The path to the data. + * @param mixed $default The default value if the data array doesn't contain the path. + * @return mixed + * @see getValueR() + */ + public function data($path, $default = '') { + $result = valr($path, $this->Data, $default); + return $result; + } + + /** + * Gets the javascript definition list used to pass data to the client. + * + * @param bool $wrap Whether or not to wrap the result in a `script` tag. + * @return string Returns a string containing the `"; + } + return $result; + } + + /** + * Returns the requested delivery type of the controller if $default is not + * provided. Sets and returns the delivery type otherwise. + * + * @param string $default One of the DELIVERY_TYPE_* constants. + */ + public function deliveryType($default = '') { + if ($default) { + // Make sure we only set a defined delivery type. + // Use constants' name pattern instead of a strict whitelist for forwards-compatibility. + if (defined('DELIVERY_TYPE_'.$default)) { + $this->_DeliveryType = $default; + } + } + + return $this->_DeliveryType; + } + + /** + * Returns the requested delivery method of the controller if $default is not + * provided. Sets and returns the delivery method otherwise. + * + * @param string $default One of the DELIVERY_METHOD_* constants. + */ + public function deliveryMethod($default = '') { + if ($default != '') { + $this->_DeliveryMethod = $default; + } + + return $this->_DeliveryMethod; + } + + /** + * + * + * @param bool $value + * @param bool $plainText + * @return mixed + */ + public function description($value = false, $plainText = false) { + if ($value != false) { + if ($plainText) { + $value = Gdn_Format::plainText($value); + } + $this->setData('_Description', $value); + } + return $this->data('_Description'); + } + + /** + * Add error messages to be displayed to the user. + * + * @since 2.0.18 + * + * @param string $messages The html of the errors to be display. + */ + public function errorMessage($messages) { + $this->_ErrorMessages = $messages; + } + + /** + * Fetches the contents of a view into a string and returns it. Returns + * false on failure. + * + * @param string $View The name of the view to fetch. If not specified, it will use the value + * of $this->View. If $this->View is not specified, it will use the value + * of $this->RequestMethod (which is defined by the dispatcher class). + * @param string $ControllerName The name of the controller that owns the view if it is not $this. + * @param string $ApplicationFolder The name of the application folder that contains the requested controller + * if it is not $this->ApplicationFolder. + */ + public function fetchView($View = '', $ControllerName = false, $ApplicationFolder = false) { + $ViewPath = $this->fetchViewLocation($View, $ControllerName, $ApplicationFolder); + + // Check to see if there is a handler for this particular extension. + $ViewHandler = Gdn::factory('ViewHandler'.strtolower(strrchr($ViewPath, '.'))); + + $ViewContents = ''; + ob_start(); + if (is_null($ViewHandler)) { + // Parse the view and place it into the asset container if it was found. + include($ViewPath); + } else { + // Use the view handler to parse the view. + $ViewHandler->render($ViewPath, $this); + } + $ViewContents = ob_get_clean(); + + return $ViewContents; + } + + /** + * Fetches the location of a view into a string and returns it. Returns + * false on failure. + * + * @param string $view The name of the view to fetch. If not specified, it will use the value + * of $this->View. If $this->View is not specified, it will use the value + * of $this->RequestMethod (which is defined by the dispatcher class). + * @param bool|string $controllerName The name of the controller that owns the view if it is not $this. + * - If the controller name is FALSE then the name of the current controller will be used. + * - If the controller name is an empty string then the view will be looked for in the base views folder. + * @param bool|string $applicationFolder The name of the application folder that contains the requested controller if it is not $this->ApplicationFolder. + * @param bool $throwError Whether to throw an error. + * @param bool $useController Whether to attach a controller to the view location. Some plugins have views that should not be looked up in a controller's view directory. + * @return string The resolved location of the view. + * @throws Exception + */ + public function fetchViewLocation($view = '', $controllerName = false, $applicationFolder = false, $throwError = true, $useController = true) { + // Accept an explicitly defined view, or look to the method that was called on this controller + if ($view == '') { + $view = $this->View; + } + + if ($view == '') { + $view = $this->RequestMethod; + } + + if ($controllerName === false) { + $controllerName = $this->ControllerName; + } + + if (stringEndsWith($controllerName, 'controller', true)) { + $controllerName = substr($controllerName, 0, -10); + } + + if (strtolower(substr($controllerName, 0, 4)) == 'gdn_') { + $controllerName = substr($controllerName, 4); + } + + if (!$applicationFolder) { + $applicationFolder = $this->ApplicationFolder; + } + + //$ApplicationFolder = strtolower($ApplicationFolder); + $controllerName = strtolower($controllerName); + if (strpos($view, DS) === false) { // keep explicit paths as they are. + $view = strtolower($view); + } + + // If this is a syndication request, append the method to the view + if ($this->SyndicationMethod == SYNDICATION_ATOM) { + $view .= '_atom'; + } elseif ($this->SyndicationMethod == SYNDICATION_RSS) { + $view .= '_rss'; + } + + $locationName = concatSep('/', strtolower($applicationFolder), $controllerName, $view); + $viewPath = val($locationName, $this->_ViewLocations, false); + if ($viewPath === false) { + // Define the search paths differently depending on whether or not we are in a plugin or application. + $applicationFolder = trim($applicationFolder, '/'); + if (stringBeginsWith($applicationFolder, 'plugins/')) { + $keyExplode = explode('/', $applicationFolder); + $pluginName = array_pop($keyExplode); + $pluginInfo = Gdn::pluginManager()->getPluginInfo($pluginName); + + $basePath = val('SearchPath', $pluginInfo); + $applicationFolder = val('Folder', $pluginInfo); + } elseif ($applicationFolder === 'core') { + $basePath = PATH_ROOT; + $applicationFolder = 'resources'; + } else { + $basePath = PATH_APPLICATIONS; + $applicationFolder = strtolower($applicationFolder); + } + + $subPaths = []; + // Define the subpath for the view. + // The $ControllerName used to default to '' instead of FALSE. + // This extra search is added for backwards-compatibility. + if (strlen($controllerName) > 0 && $useController) { + $subPaths[] = "views/$controllerName/$view"; + } else { + $subPaths[] = "views/$view"; + + if ($useController) { + $subPaths[] = 'views/'.stringEndsWith($this->ControllerName, 'Controller', true, true)."/$view"; + } + } + + // Views come from one of four places: + $viewPaths = []; + + // 1. An explicitly defined path to a view + if (strpos($view, DS) !== false && stringBeginsWith($view, PATH_ROOT)) { + $viewPaths[] = $view; + } + + if ($this->Theme) { + // 2. Application-specific theme view. eg. /path/to/application/themes/theme_name/app_name/views/controller_name/ + foreach ($subPaths as $subPath) { + $viewPaths[] = PATH_THEMES."/{$this->Theme}/$applicationFolder/$subPath.*"; + // $ViewPaths[] = combinePaths(array(PATH_THEMES, $this->Theme, $ApplicationFolder, 'views', $ControllerName, $View . '.*')); + } + + // 3. Garden-wide theme view. eg. /path/to/application/themes/theme_name/views/controller_name/ + foreach ($subPaths as $subPath) { + $viewPaths[] = PATH_THEMES."/{$this->Theme}/$subPath.*"; + //$ViewPaths[] = combinePaths(array(PATH_THEMES, $this->Theme, 'views', $ControllerName, $View . '.*')); + } + } + + // 4. Application/plugin default. eg. /path/to/application/app_name/views/controller_name/ + foreach ($subPaths as $subPath) { + $viewPaths[] = "$basePath/$applicationFolder/$subPath.*"; + //$ViewPaths[] = combinePaths(array(PATH_APPLICATIONS, $ApplicationFolder, 'views', $ControllerName, $View . '.*')); + } + + // Find the first file that matches the path. + $viewPath = false; + foreach ($viewPaths as $glob) { + $paths = safeGlob($glob); + if (is_array($paths) && count($paths) > 0) { + $viewPath = $paths[0]; + break; + } + } + //$ViewPath = Gdn_FileSystem::exists($ViewPaths); + + $this->_ViewLocations[$locationName] = $viewPath; + } + // echo '
['.$LocationName.'] RETURNS ['.$ViewPath.']
'; + if ($viewPath === false && $throwError) { + Gdn::dispatcher()->passData('ViewPaths', $viewPaths); + throw notFoundException('View'); +// trigger_error(errorMessage("Could not find a '$View' view for the '$ControllerName' controller in the '$ApplicationFolder' application.", $this->ClassName, 'FetchViewLocation'), E_USER_ERROR); + } + + return $viewPath; + } + + /** + * Cleanup any remaining resources for this controller. + */ + public function finalize() { + $this->fireAs('Gdn_Controller')->fireEvent('Finalize'); + } + + /** + * + * + * @param string $assetName + */ + public function getAsset($assetName) { + if (!array_key_exists($assetName, $this->Assets)) { + return ''; + } + if (!is_array($this->Assets[$assetName])) { + return $this->Assets[$assetName]; + } + + // Include the module sort + $modules = array_change_key_case(c('Modules', [])); + $sortContainer = strtolower($this->ModuleSortContainer); + $applicationName = strtolower($this->Application); + + if ($this->ModuleSortContainer === false) { + $moduleSort = false; // no sort wanted + } elseif (isset($modules[$sortContainer][$assetName])) { + $moduleSort = $modules[$sortContainer][$assetName]; // explicit sort + } elseif (isset($modules[$applicationName][$assetName])) { + $moduleSort = $modules[$applicationName][$assetName]; // application default sort + } + + // Get all the assets for this AssetContainer + $thisAssets = $this->Assets[$assetName]; + $assets = []; + + if (isset($moduleSort) && is_array($moduleSort)) { + // There is a specified sort so sort by it. + foreach ($moduleSort as $name) { + if (array_key_exists($name, $thisAssets)) { + $assets[] = $thisAssets[$name]; + unset($thisAssets[$name]); + } + } + } + + // Pick up any leftover assets that werent explicitly sorted + foreach ($thisAssets as $name => $asset) { + $assets[] = $asset; + } + + if (count($assets) == 0) { + return ''; + } elseif (count($assets) == 1) { + return $assets[0]; + } else { + $result = new Gdn_ModuleCollection(); + $result->Items = $assets; + return $result; + } + } + + /** + * Get the current Head. + * + * @return mixed + */ + public function getHead() { + return $this->Head; + } + + /** + * + */ + public function getImports() { + if (!isset($this->Uses) || !is_array($this->Uses)) { + return; + } + + // Load any classes in the uses array and make them properties of this class + foreach ($this->Uses as $Class) { + if (strlen($Class) >= 4 && substr_compare($Class, 'Gdn_', 0, 4) == 0) { + $Property = substr($Class, 4); + } else { + $Property = $Class; + } + + // Find the class and instantiate an instance.. + if (Gdn::factoryExists($Property)) { + $this->$Property = Gdn::factory($Property); + } + if (Gdn::factoryExists($Class)) { + // Instantiate from the factory. + $this->$Property = Gdn::factory($Class); + } elseif (class_exists($Class)) { + // Instantiate as an object. + $this->$Property = new $Class(); + } else { + trigger_error(errorMessage('The "'.$Class.'" class could not be found.', $this->ClassName, '__construct'), E_USER_ERROR); + } + } + } + + /** + * + * + * @return array + */ + public function getJson() { + return $this->_Json; + } + + /** + * Allows images to be specified for the page, to be used by the head module + * to add facebook open graph information. + * + * @param mixed $img An image or array of image urls. + * @return array The array of image urls. + */ + public function image($img = false) { + if ($img) { + if (!is_array($img)) { + $img = [$img]; + } + + $currentImages = $this->data('_Images'); + if (!is_array($currentImages)) { + $this->setData('_Images', $img); + } else { + $images = array_unique(array_merge($currentImages, $img)); + $this->setData('_Images', $images); + } + } + $images = $this->data('_Images'); + return is_array($images) ? $images : []; + } + + /** + * Add an "inform" message to be displayed to the user. + * + * @since 2.0.18 + * + * @param string $message The message to be displayed. + * @param mixed $options An array of options for the message. If not an array, it is assumed to be a string of CSS classes to apply to the message. + */ + public function informMessage($message, $options = ['CssClass' => 'Dismissable AutoDismiss']) { + // If $Options isn't an array of options, accept it as a string of css classes to be assigned to the message. + if (!is_array($options)) { + $options = ['CssClass' => $options]; + } + + if (!$message && !array_key_exists('id', $options)) { + return; + } + + $options['Message'] = $message; + $this->_InformMessages[] = $options; + } + + /** + * The initialize method is called by the dispatcher after the constructor + * has completed, objects have been passed along, assets have been + * retrieved, and before the requested method fires. Use it in any extended + * controller to do things like loading script and CSS into the head. + */ + public function initialize() { + if (in_array($this->SyndicationMethod, [SYNDICATION_ATOM, SYNDICATION_RSS])) { + $this->_Headers['Content-Type'] = 'text/xml; charset=utf-8'; + } + + if (is_object($this->Menu)) { + $this->Menu->Sort = Gdn::config('Garden.Menu.Sort'); + } + $this->fireEvent('Initialize'); + } + + /** + * + * + * @return array + */ + public function jsFiles() { + return $this->_JsFiles; + } + + /** + * Determines whether a method on this controller is internal and can't be dispatched. + * + * @param string $methodName The name of the method. + * @return bool Returns true if the method is internal or false otherwise. + */ + public function isInternal($methodName) { + $result = substr($methodName, 0, 1) === '_' || in_array(strtolower($methodName), $this->internalMethods); + return $result; + } + + /** + * Determine if this is a valid API v1 (Simple API) request. Write methods optionally require valid authentication. + * + * @param bool $validateAuth Verify access token has been validated for write methods. + * @return bool + */ + private function isLegacyAPI($validateAuth = true) { + $result = false; + + // API v1 tags the dispatcher with an "API" property. + if (val('API', Gdn::dispatcher())) { + $method = strtolower(Gdn::request()->getMethod()); + $readMethods = ['get']; + if ($validateAuth && !in_array($method, $readMethods)) { + /** + * API v1 bypasses TK checks if the access token was valid. + * Do not trust the presence of a valid user ID. An API call could be made by a signed-in user without using an access token. + */ + $result = Gdn::session()->validateTransientKey(null) === true; + } else { + $result = true; + } + } + + return $result; + } + + /** + * If JSON is going to be sent to the client, this method allows you to add + * extra values to the JSON array. + * + * @param string $key The name of the array key to add. + * @param mixed $value The value to be added. If null, then it won't be set. + * @return mixed The value at the key. + */ + public function json($key, $value = null) { + if (!is_null($value)) { + $this->_Json[$key] = $value; + } + return val($key, $this->_Json, null); + } + + /** + * + * + * @param $target + * @param $data + * @param string $type + */ + public function jsonTarget($target, $data, $type = 'Html') { + $item = ['Target' => $target, 'Data' => $data, 'Type' => $type]; + + if (!array_key_exists('Targets', $this->_Json)) { + $this->_Json['Targets'] = [$item]; + } else { + $this->_Json['Targets'][] = $item; + } + } + + /** + * Define & return the master view. + */ + public function masterView() { + // Define some default master views unless one was explicitly defined + if ($this->MasterView == '') { + // If this is a syndication request, use the appropriate master view + if ($this->SyndicationMethod == SYNDICATION_ATOM) { + $this->MasterView = 'atom'; + } elseif ($this->SyndicationMethod == SYNDICATION_RSS) { + $this->MasterView = 'rss'; + } else { + $this->MasterView = 'default'; // Otherwise go with the default + } + } + return $this->MasterView; + } + + /** + * Gets or sets the name of the page for the controller. + * The page name is meant to be a friendly name suitable to be consumed by developers. + * + * @param string|NULL $value A new value to set. + */ + public function pageName($value = null) { + if ($value !== null) { + $this->_PageName = $value; + return $value; + } + + if ($this->_PageName === null) { + if ($this->ControllerName) { + $name = $this->ControllerName; + } else { + $name = get_class($this); + } + $name = strtolower($name); + + if (stringEndsWith($name, 'controller', false)) { + $name = substr($name, 0, -strlen('controller')); + } + + return $name; + } else { + return $this->_PageName; + } + } + + /** + * Checks that the user has the specified permissions. If the user does not, they are redirected to the DefaultPermission route. + * + * @param mixed $permission A permission or array of permission names required to access this resource. + * @param bool $fullMatch If $permission is an array, $fullMatch indicates if all permissions specified are required. If false, the user only needs one of the specified permissions. + * @param string $junctionTable The name of the junction table for a junction permission. + * @param int $junctionID The ID of the junction permission. + */ + public function permission($permission, $fullMatch = true, $junctionTable = '', $junctionID = '') { + $session = Gdn::session(); + + if (!$session->checkPermission($permission, $fullMatch, $junctionTable, $junctionID)) { + Logger::logAccess( + 'security_denied', + Logger::NOTICE, + '{username} was denied access to {path}.', + [ + 'permission' => $permission, + ] + ); + + if (!$session->isValid() && $this->deliveryType() == DELIVERY_TYPE_ALL) { + redirectTo('/entry/signin?Target='.urlencode($this->Request->pathAndQuery())); + } else { + Gdn::dispatcher()->dispatch('DefaultPermission'); + exit(); + } + } else { + $required = array_intersect((array)$permission, ['Garden.Settings.Manage', 'Garden.Moderation.Manage']); + if (!empty($required)) { + Logger::logAccess('security_access', Logger::INFO, "{username} accessed {path}."); + } + } + } + + /** + * Stop the current action and re-authenticate, if necessary. + * + * @param array $options Setting key 'ForceTimeout' to `true` will ignore the cooldown window between prompts. + */ + public function reauth($options = []) { + // Make sure we're logged in... + if (Gdn::session()->UserID == 0) { + return; + } + + // ...aren't in an API v1 call... + if ($this->isLegacyAPI()) { + return; + } + + // ...and have a proper password. + $user = Gdn::userModel()->getID(Gdn::session()->UserID); + if (val('HashMethod', $user) === 'Random') { + return; + } + + // If the user has logged in recently enough, don't make them login again. + $lastAuthenticated = Gdn::authenticator()->identity()->getAuthTime(); + $forceTimeout = $options['ForceTimeout'] ?? false; + if ($lastAuthenticated > 0 && !$forceTimeout) { + $sinceAuth = time() - $lastAuthenticated; + if ($sinceAuth < self::REAUTH_TIMEOUT) { + return; + } + } + + Gdn::dispatcher()->dispatch('/profile/authenticate', false); + exit(); + } + + /** + * Removes a CSS file from the collection. + * + * @param string $fileName The CSS file to search for. + */ + public function removeCssFile($fileName) { + foreach ($this->_CssFiles as $key => $fileInfo) { + if ($fileInfo['FileName'] == $fileName) { + unset($this->_CssFiles[$key]); + return; + } + } + } + + /** + * Removes a JS file from the collection. + * + * @param string $fileName The JS file to search for. + */ + public function removeJsFile($fileName) { + foreach ($this->_JsFiles as $key => $fileInfo) { + if ($fileInfo['FileName'] == $fileName) { + unset($this->_JsFiles[$key]); + return; + } + } + } + + /** + * Defines & retrieves the view and master view. Renders all content within + * them to the screen. + * + * @param string $view + * @param string $controllerName + * @param string $applicationFolder + * @param string $assetName The name of the asset container that the content should be rendered in. + */ + public function xRender($view = '', $controllerName = false, $applicationFolder = false, $assetName = 'Content') { + // Remove the deliver type and method from the query string so they don't corrupt calls to Url. + $this->Request->setValueOn(Gdn_Request::INPUT_GET, 'DeliveryType', null); + $this->Request->setValueOn(Gdn_Request::INPUT_GET, 'DeliveryMethod', null); + + Gdn::pluginManager()->callEventHandlers($this, $this->ClassName, $this->RequestMethod, 'Render'); + + if ($this->_DeliveryType == DELIVERY_TYPE_NONE) { + return; + } + + // Handle deprecated StatusMessage values that may have been added by plugins + $this->informMessage($this->StatusMessage); + + // If there were uncontrolled errors above the json data, wipe them out + // before fetching it (otherwise the json will not be properly parsed + // by javascript). + if ($this->_DeliveryMethod == DELIVERY_METHOD_JSON) { + if (ob_get_level()) { + ob_clean(); + } + $this->contentType('application/json; charset=utf-8'); + $this->setHeader('X-Content-Type-Options', 'nosniff'); + + // Cross-Origin Resource Sharing (CORS) + $this->setAccessControl(); + } + + if ($this->_DeliveryMethod == DELIVERY_METHOD_TEXT) { + $this->contentType('text/plain'); + } + + // Send headers to the browser + $this->sendHeaders(); + + // Make sure to clear out the content asset collection if this is a syndication request + if ($this->SyndicationMethod !== SYNDICATION_NONE) { + $this->Assets['Content'] = []; + } + + // Define the view + if (!in_array($this->_DeliveryType, [DELIVERY_TYPE_BOOL, DELIVERY_TYPE_DATA])) { + $view = $this->fetchView($view, $controllerName, $applicationFolder); + // Add the view to the asset container if necessary + if ($this->_DeliveryType != DELIVERY_TYPE_VIEW) { + $this->addAsset($assetName, $view, 'Content'); + } + } + + // Redefine the view as the entire asset contents if necessary + if ($this->_DeliveryType == DELIVERY_TYPE_ASSET) { + $view = $this->getAsset($assetName); + } elseif ($this->_DeliveryType == DELIVERY_TYPE_BOOL) { + // Or as a boolean if necessary + $view = true; + if (property_exists($this, 'Form') && is_object($this->Form)) { + $view = $this->Form->errorCount() > 0 ? false : true; + } + } + + if ($this->_DeliveryType == DELIVERY_TYPE_MESSAGE && $this->Form) { + $view = $this->Form->errors(); + } + + if ($this->_DeliveryType == DELIVERY_TYPE_DATA) { + $exitRender = $this->renderData(); + if ($exitRender) { + return; + } + } + + if ($this->_DeliveryMethod == DELIVERY_METHOD_JSON) { + // Format the view as JSON with some extra information about the + // success status of the form so that jQuery knows what to do + // with the result. + if ($this->_FormSaved === '') { // Allow for override + $this->_FormSaved = (property_exists($this, 'Form') && $this->Form->errorCount() == 0) ? true : false; + } + + $this->setJson('FormSaved', $this->_FormSaved); + $this->setJson('DeliveryType', $this->_DeliveryType); + $this->setJson('Data', ($view instanceof Gdn_IModule) ? $view->toString() : $view); + $this->setJson('InformMessages', $this->_InformMessages); + $this->setJson('ErrorMessages', $this->_ErrorMessages); + if ($this->redirectTo !== null) { + // See redirectTo function for details about encoding backslashes. + $this->setJson('RedirectTo', str_replace('\\', '%5c', $this->redirectTo)); + $this->setJson('RedirectUrl', str_replace('\\', '%5c', $this->redirectTo)); + } else { + $this->setJson('RedirectTo', str_replace('\\', '%5c', $this->RedirectUrl)); + $this->setJson('RedirectUrl', str_replace('\\', '%5c', $this->RedirectUrl)); + } + + // Make sure the database connection is closed before exiting. + $this->finalize(); + + if (!check_utf8($this->_Json['Data'])) { + $this->_Json['Data'] = utf8_encode($this->_Json['Data']); + } + + $json = ipDecodeRecursive($this->_Json); + $json = json_encode($json, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT); + $this->_Json['Data'] = $json; + exit($this->_Json['Data']); + } else { + if ($this->SyndicationMethod === SYNDICATION_NONE) { + if (count($this->_InformMessages) > 0) { + $this->addDefinition('InformMessageStack', json_encode($this->_InformMessages, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + } + if ($this->redirectTo !== null) { + $this->addDefinition('RedirectTo', str_replace('\\', '%5c', $this->redirectTo)); + $this->addDefinition('RedirectUrl', str_replace('\\', '%5c', $this->redirectTo)); + } else { + $this->addDefinition('RedirectTo', str_replace('\\', '%5c', $this->RedirectUrl)); + $this->addDefinition('RedirectUrl', str_replace('\\', '%5c', $this->RedirectUrl)); + } + } + + if ($this->_DeliveryMethod == DELIVERY_METHOD_XHTML && debug()) { + $this->addModule('TraceModule'); + } + + // Render + if ($this->_DeliveryType == DELIVERY_TYPE_BOOL) { + echo $view ? 'TRUE' : 'FALSE'; + } elseif ($this->_DeliveryType == DELIVERY_TYPE_ALL) { + // Render + $this->renderMaster(); + } else { + if ($view instanceof Gdn_IModule) { + $view->render(); + } else { + echo $view; + } + } + } + } + + /** + * Set Access-Control-Allow-Origin header. + * + * If a Origin header is sent by the client, attempt to verify it against the list of + * trusted domains in Garden.TrustedDomains. If the value of Origin is verified as + * being part of a trusted domain, add the Access-Control-Allow-Origin header to the + * response using the client's Origin header value. + */ + protected function setAccessControl() { + $origin = Gdn::request()->getValueFrom(Gdn_Request::INPUT_SERVER, 'HTTP_ORIGIN', false); + if ($origin) { + $originHost = parse_url($origin, PHP_URL_HOST); + if ($originHost && isTrustedDomain($originHost)) { + $this->setHeader('Access-Control-Allow-Origin', $origin); + $this->setHeader("Access-Control-Allow-Credentials", "true"); + } + } + } + + /** + * Searches $this->Assets for a key with $assetName and renders all items + * within that array element to the screen. Note that any element in + * $this->Assets can contain an array of elements itself. This way numerous + * assets can be rendered one after another in one place. + * + * @param string $assetName The name of the asset to be rendered (the key related to the asset in + * the $this->Assets associative array). + */ + public function renderAsset($assetName) { + $asset = $this->getAsset($assetName); + + $this->EventArguments['AssetName'] = $assetName; + $this->fireEvent('BeforeRenderAsset'); + + //$LengthBefore = ob_get_length(); + + if (is_string($asset)) { + echo $asset; + } else { + $asset->AssetName = $assetName; + $asset->render(); + } + + $this->fireEvent('AfterRenderAsset'); + } + + /** + * Render the data array. + * + * @param null $Data + * @return bool + * @throws Exception + */ + public function renderData($Data = null) { + if ($Data === null) { + $Data = []; + + // Remove standard and "protected" data from the top level. + foreach ($this->Data as $Key => $Value) { + if ($Key && in_array($Key, ['Title', 'Breadcrumbs', 'isHomepage'])) { + continue; + } + if (isset($Key[0]) && $Key[0] === '_') { + continue; // protected + } + $Data[$Key] = $Value; + } + unset($this->Data); + } + + // Massage the data for better rendering. + foreach ($Data as $Key => $Value) { + if (is_a($Value, 'Gdn_DataSet')) { + $Data[$Key] = $Value->resultArray(); + } + } + + $CleanOutut = c('Api.Clean', true); + if ($CleanOutut) { + // Remove values that should not be transmitted via api + $Remove = ['Password', 'HashMethod', 'TransientKey', 'Permissions', 'Attributes', 'AccessToken']; + + // Remove PersonalInfo values for unprivileged requests. + if (!Gdn::session()->checkPermission('Garden.Moderation.Manage')) { + $Remove[] = 'InsertIPAddress'; + $Remove[] = 'UpdateIPAddress'; + $Remove[] = 'LastIPAddress'; + $Remove[] = 'AllIPAddresses'; + $Remove[] = 'Fingerprint'; + $Remove[] = 'DateOfBirth'; + $Remove[] = 'Preferences'; + $Remove[] = 'Banned'; + $Remove[] = 'Admin'; + $Remove[] = 'Verified'; + $Remove[] = 'DiscoveryText'; + $Remove[] = 'InviteUserID'; + $Remove[] = 'DateSetInvitations'; + $Remove[] = 'CountInvitations'; + $Remove[] = 'CountNotifications'; + $Remove[] = 'CountBookmarks'; + $Remove[] = 'CountDrafts'; + $Remove[] = 'Punished'; + $Remove[] = 'Troll'; + + + if (empty($Data['UserID']) || $Data['UserID'] != Gdn::session()->UserID) { + if (c('Api.Clean.Email', true)) { + $Remove[] = 'Email'; + } + $Remove[] = 'Confirmed'; + $Remove[] = 'HourOffset'; + $Remove[] = 'Gender'; + } + } + $Data = removeKeysFromNestedArray($Data, $Remove); + } + + if (debug() && $this->deliveryMethod() !== DELIVERY_METHOD_XML && $Trace = trace()) { + // Clear passwords from the trace. + array_walk_recursive($Trace, function (&$Value, $Key) { + if (in_array(strtolower($Key), ['password'])) { + $Value = '***'; + } + }); + $Data['Trace'] = $Trace; + } + + // Make sure the database connection is closed before exiting. + $this->EventArguments['Data'] = &$Data; + $this->finalize(); + + // Add error information from the form. + if (isset($this->Form) && sizeof($this->Form->validationResults())) { + $this->statusCode(400); + $Data['Code'] = 400; + $Data['Exception'] = Gdn_Validation::resultsAsText($this->Form->validationResults()); + } + + $this->sendHeaders(); + + $Data = ipDecodeRecursive($Data); + + // Check for a special view. + $ViewLocation = $this->fetchViewLocation(($this->View ? $this->View : $this->RequestMethod).'_'.strtolower($this->deliveryMethod()), false, false, false); + if (file_exists($ViewLocation)) { + include $ViewLocation; + return; + } + + // Add schemes to to urls. + if (!c('Garden.AllowSSL') || c('Garden.ForceSSL')) { + $r = array_walk_recursive($Data, ['Gdn_Controller', '_FixUrlScheme'], Gdn::request()->scheme()); + } + + if (ob_get_level()) { + ob_clean(); + } + switch ($this->deliveryMethod()) { + case DELIVERY_METHOD_XML: + safeHeader('Content-Type: text/xml', true); + echo ''."\n"; + $this->_renderXml($Data); + return true; + break; + case DELIVERY_METHOD_PLAIN: + return true; + break; + case DELIVERY_METHOD_JSON: + default: + $jsonData = jsonEncodeChecked($Data); + + if (($Callback = $this->Request->get('callback', false)) && $this->allowJSONP()) { + safeHeader('Content-Type: application/javascript; charset=utf-8', true); + // This is a jsonp request. + echo "{$Callback}({$jsonData});"; + return true; + } else { + safeHeader('Content-Type: application/json; charset=utf-8', true); + // This is a regular json request. + echo $jsonData; + return true; + } + break; + } + return false; + } + + /** + * Render a page that hosts a react component. + */ + public function renderReact() { + if (!$this->data('hasPanel')) { + $this->CssClass .= ' NoPanel'; + } + $this->render('react', '', 'core'); + } + + /** + * + * + * @param $value + * @param $key + * @param $scheme + */ + protected static function _fixUrlScheme(&$value, $key, $scheme) { + if (!is_string($value)) { + return; + } + + if (substr($value, 0, 2) == '//' && substr($key, -3) == 'Url') { + $value = $scheme.':'.$value; + } + } + + /** + * A simple default method for rendering xml. + * + * @param mixed $data The data to render. This is usually $this->Data. + * @param string $node The name of the root node. + * @param string $indent The indent before the data for layout that is easier to read. + */ + protected function _renderXml($data, $node = 'Data', $indent = '') { + // Handle numeric arrays. + if (is_numeric($node)) { + $node = 'Item'; + } + + if (!$node) { + return; + } + + echo "$indent<$node>"; + + if (is_scalar($data)) { + echo htmlspecialchars($data); + } else { + $data = (array)$data; + if (count($data) > 0) { + foreach ($data as $key => $value) { + echo "\n"; + $this->_renderXml($value, $key, $indent.' '); + } + echo "\n"; + } + } + echo ""; + } + + /** + * Render an exception as the sole output. + * + * @param Exception $ex The exception to render. + */ + public function renderException($ex) { + if ($this->deliveryMethod() == DELIVERY_METHOD_XHTML) { + try { + // Pick our route. + switch ($ex->getCode()) { + case 401: + case 403: + $route = 'DefaultPermission'; + break; + case 404: + $route = 'Default404'; + break; + default: + $route = '/home/error'; + } + + // Redispatch to our error handler. + if (is_a($ex, 'Gdn_UserException')) { + // UserExceptions provide more info. + Gdn::dispatcher() + ->passData('Code', $ex->getCode()) + ->passData('Exception', $ex->getMessage()) + ->passData('Message', $ex->getMessage()) + ->passData('Trace', $ex->getTraceAsString()) + ->passData('Url', url()) + ->passData('Breadcrumbs', $this->data('Breadcrumbs', [])) + ->dispatch($route); + } elseif (in_array($ex->getCode(), [401, 403, 404])) { + // Default forbidden & not found codes. + Gdn::dispatcher() + ->passData('Message', $ex->getMessage()) + ->passData('Url', url()) + ->dispatch($route); + } else { + // I dunno! Barf. + gdn_ExceptionHandler($ex); + } + } catch (Exception $ex2) { + gdn_ExceptionHandler($ex); + } + return; + } + + // Make sure the database connection is closed before exiting. + $this->finalize(); + $this->sendHeaders(); + + $code = $ex->getCode(); + $data = ['Code' => $code, 'Exception' => $ex->getMessage(), 'Class' => get_class($ex)]; + + if (debug()) { + if ($trace = trace()) { + // Clear passwords from the trace. + array_walk_recursive($trace, function (&$value, $key) { + if (in_array(strtolower($key), ['password'])) { + $value = '***'; + } + }); + $data['Trace'] = $trace; + } + + if (!is_a($ex, 'Gdn_UserException')) { + $data['StackTrace'] = $ex->getTraceAsString(); + } + + $data['Data'] = $this->Data; + } + + // Try cleaning out any notices or errors. + if (ob_get_level()) { + ob_clean(); + } + + if ($code >= 400 && $code <= 505) { + safeHeader("HTTP/1.0 $code", true, $code); + } else { + safeHeader('HTTP/1.0 500', true, 500); + } + + + switch ($this->deliveryMethod()) { + case DELIVERY_METHOD_JSON: + if (($callback = $this->Request->getValueFrom(Gdn_Request::INPUT_GET, 'callback', false)) && $this->allowJSONP()) { + safeHeader('Content-Type: application/javascript; charset=utf-8', true); + // This is a jsonp request. + exit($callback.'('.json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT).');'); + } else { + safeHeader('Content-Type: application/json; charset=utf-8', true); + // This is a regular json request. + exit(json_encode($data, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + } + break; +// case DELIVERY_METHOD_XHTML: +// gdn_ExceptionHandler($Ex); +// break; + case DELIVERY_METHOD_XML: + safeHeader('Content-Type: text/xml; charset=utf-8', true); + array_map('htmlspecialchars', $data); + exit("{$data['Code']}{$data['Class']}{$data['Exception']}"); + break; + default: + safeHeader('Content-Type: text/plain; charset=utf-8', true); + exit($ex->getMessage()); + } + } + + /** + * + */ + public function renderMaster() { + // Build the master view if necessary + if (in_array($this->_DeliveryType, [DELIVERY_TYPE_ALL])) { + $this->MasterView = $this->masterView(); + + // Only get css & ui Components if this is NOT a syndication request + if ($this->SyndicationMethod == SYNDICATION_NONE && is_object($this->Head)) { + + $CssAnchors = LegacyAssetModel::getAnchors(); + + $this->EventArguments['CssFiles'] = &$this->_CssFiles; + $this->fireEvent('BeforeAddCss'); + + $ETag = LegacyAssetModel::eTag(); + $ThemeType = isMobile() ? 'mobile' : 'desktop'; + /* @var LegacyAssetModel $AssetModel */ + $AssetModel = Gdn::getContainer()->get(LegacyAssetModel::class); + + // And now search for/add all css files. + foreach ($this->_CssFiles as $CssInfo) { + $CssFile = $CssInfo['FileName']; + if (!array_key_exists('Options', $CssInfo) || !is_array($CssInfo['Options'])) { + $CssInfo['Options'] = []; + } + $Options = &$CssInfo['Options']; + + // style.css and admin.css deserve some custom processing. + if (in_array($CssFile, $CssAnchors)) { + // Grab all of the css files from the asset model. + $CssFiles = $AssetModel->getCssFiles($ThemeType, ucfirst(substr($CssFile, 0, -4)), $ETag, $_, $this->Theme); + foreach ($CssFiles as $Info) { + $this->Head->addCss($Info[1], 'all', true, $CssInfo); + } + continue; + } + + $AppFolder = $CssInfo['AppFolder']; + $LookupFolder = !empty($AppFolder) ? $AppFolder : $this->ApplicationFolder; + $Search = LegacyAssetModel::cssPath($CssFile, $LookupFolder, $ThemeType); + if (!$Search) { + continue; + } + + list($Path, $UrlPath) = $Search; + + if (isUrl($Path)) { + $this->Head->addCss($Path, 'all', val('AddVersion', $Options, true), $Options); + continue; + + } else { + // Check to see if there is a CSS cacher. + $CssCacher = Gdn::factory('CssCacher'); + if (!is_null($CssCacher)) { + $Path = $CssCacher->get($Path, $AppFolder); + } + + if ($Path !== false) { + $Path = substr($Path, strlen(PATH_ROOT)); + $Path = str_replace(DS, '/', $Path); + $this->Head->addCss($Path, 'all', true, $Options); + } + } + } + + // Add a custom js file. + if (arrayHasValue($this->_CssFiles, 'style.css')) { + $this->addJsFile('custom.js'); // only to non-admin pages. + } + + $Cdns = []; + + // And now search for/add all JS files. + $this->EventArguments['Cdns'] = &$Cdns; + $this->fireEvent('AfterJsCdns'); + + // Add inline content meta. + $this->Head->addScript('', 'text/javascript', false, ['content' => $this->definitionList(false)]); + + // Add legacy style scripts + foreach ($this->_JsFiles as $Index => $JsInfo) { + $JsFile = $JsInfo['FileName']; + if (!is_array($JsInfo['Options'])) { + $JsInfo['Options'] = []; + } + $Options = &$JsInfo['Options']; + + if ($this->useDeferredLegacyScripts) { + $Options['defer'] = 'defer'; + } + + if (isset($Cdns[$JsFile])) { + $JsFile = $Cdns[$JsFile]; + } + + $AppFolder = $JsInfo['AppFolder']; + $LookupFolder = !empty($AppFolder) ? $AppFolder : $this->ApplicationFolder; + $Search = LegacyAssetModel::jsPath($JsFile, $LookupFolder, $ThemeType); + if (!$Search) { + continue; + } + + list($Path, $UrlPath) = $Search; + + if ($Path !== false) { + $AddVersion = true; + if (!isUrl($Path)) { + $Path = substr($Path, strlen(PATH_ROOT)); + $Path = str_replace(DS, '/', $Path); + $AddVersion = val('AddVersion', $Options, true); + } + $this->Head->addScript($Path, 'text/javascript', $AddVersion, $Options); + continue; + } + } + + $this->addWebpackAssets(); + $this->addThemeAssets(); + + // Add preloaded redux actions. + $this->Head->addScript( + '', + 'text/javascript', + false, + ['content' => $this->getReduxActionsAsJsVariable()] + ); + } + + // Add the favicon. + $Favicon = c('Garden.FavIcon'); + if ($Favicon) { + $this->Head->setFavIcon(Gdn_Upload::url($Favicon)); + } + + $touchIcon = c('Garden.TouchIcon'); + if ($touchIcon) { + $this->Head->setTouchIcon(Gdn_Upload::url($touchIcon)); + } + + // Add address bar color. + $mobileAddressBarColor = c('Garden.MobileAddressBarColor'); + if (!empty($mobileAddressBarColor)) { + $this->Head->setMobileAddressBarColor($mobileAddressBarColor); + } + + // Make sure the head module gets passed into the assets collection. + $this->addModule('Head'); + } + + // Master views come from one of four places: + $MasterViewPaths = []; + + if (strpos($this->MasterView, '/') !== false) { + $MasterViewPaths[] = combinePaths([PATH_ROOT, str_replace('/', DS, $this->MasterView).'.master*']); + } else { + if ($this->Theme) { + // 1. Application-specific theme view. eg. root/themes/theme_name/app_name/views/ + $MasterViewPaths[] = combinePaths([PATH_THEMES, $this->Theme, $this->ApplicationFolder, 'views', $this->MasterView.'.master*']); + // 2. Garden-wide theme view. eg. /path/to/application/themes/theme_name/views/ + $MasterViewPaths[] = combinePaths([PATH_THEMES, $this->Theme, 'views', $this->MasterView.'.master*']); + } + // 3. Plugin default. eg. root/plugin_name/views/ + $MasterViewPaths[] = combinePaths([PATH_ROOT, $this->ApplicationFolder, 'views', $this->MasterView.'.master*']); + // 4. Application default. eg. root/app_name/views/ + $MasterViewPaths[] = combinePaths([PATH_APPLICATIONS, $this->ApplicationFolder, 'views', $this->MasterView.'.master*']); + // 5. Garden default. eg. root/dashboard/views/ + $MasterViewPaths[] = combinePaths([PATH_APPLICATIONS, 'dashboard', 'views', $this->MasterView.'.master*']); + } + + // Find the first file that matches the path. + $MasterViewPath = false; + foreach ($MasterViewPaths as $Glob) { + $Paths = safeGlob($Glob); + if (is_array($Paths) && count($Paths) > 0) { + $MasterViewPath = $Paths[0]; + break; + } + } + + $this->EventArguments['MasterViewPath'] = &$MasterViewPath; + $this->fireEvent('BeforeFetchMaster'); + + if ($MasterViewPath === false) { + trigger_error(errorMessage("Could not find master view: {$this->MasterView}.master*", $this->ClassName, '_FetchController'), E_USER_ERROR); + } + + /// A unique identifier that can be used in the body tag of the master view if needed. + $ControllerName = $this->ClassName; + // Strip "Controller" from the body identifier. + if (substr($ControllerName, -10) == 'Controller') { + $ControllerName = substr($ControllerName, 0, -10); + } + + // Strip "Gdn_" from the body identifier. + if (substr($ControllerName, 0, 4) == 'Gdn_') { + $ControllerName = substr($ControllerName, 4); + } + + $this->setData('CssClass', ucfirst($this->Application).' '.$ControllerName.' is'.ucfirst($ThemeType).' '.$this->RequestMethod.' '.$this->CssClass, true); + + // Check to see if there is a handler for this particular extension. + $ViewHandler = Gdn::factory('ViewHandler'.strtolower(strrchr($MasterViewPath, '.'))); + if (is_null($ViewHandler)) { + $BodyIdentifier = strtolower($this->ApplicationFolder.'_'.$ControllerName.'_'.Gdn_Format::alphaNumeric(strtolower($this->RequestMethod))); + include($MasterViewPath); + } else { + $ViewHandler->render($MasterViewPath, $this); + } + } + + /** + * Get theming assets for the page. + */ + private function addThemeAssets() { + if (!$this->allowCustomTheming || $this->_DeliveryType !== DELIVERY_TYPE_ALL) { + // We only want to load theme data for full page loads & controllers that require theming data. + return; + } + + /** @var ThemePreloadProvider $themeProvider */ + $themeProvider = Gdn::getContainer()->get(ThemePreloadProvider::class); + + $this->registerReduxActionProvider($themeProvider); + $themeScript = $themeProvider->getThemeScript(); + if ($themeScript !== null) { + $this->Head->addScript($themeScript->getWebPath()); + } + } + + /** + * Add the assets from WebpackAssetProvider to the page. + */ + private function addWebpackAssets() { + // Webpack based scripts + /** @var \Vanilla\Web\Asset\WebpackAssetProvider $webpackAssetProvider */ + $webpackAssetProvider = Gdn::getContainer()->get(\Vanilla\Web\Asset\WebpackAssetProvider::class); + + $polyfillContent = $webpackAssetProvider->getInlinePolyfillContents(); + $this->Head->addScript(null, null, false, ["content" => $polyfillContent]); + + // Add the built webpack javascript files. + $section = $this->MasterView === 'admin' ? 'admin' : 'forum'; + $jsAssets = $webpackAssetProvider->getScripts($section); + foreach ($jsAssets as $asset) { + $this->Head->addScript($asset->getWebPath(), 'text/javascript', false, ['defer' => 'defer']); + } + + // The the built stylesheets + $styleAssets = $webpackAssetProvider->getStylesheets($section); + foreach ($styleAssets as $asset) { + $this->Head->addCss($asset->getWebPath(), null, false); + } + } + + /** + * Sends all headers in $this->_Headers (defined with $this->setHeader()) to the browser. + */ + public function sendHeaders() { + // TODO: ALWAYS RENDER OR REDIRECT FROM THE CONTROLLER OR HEADERS WILL NOT BE SENT!! PUT THIS IN DOCS!!! + foreach ($this->_Headers as $name => $value) { + if ($name !== 'Status') { + safeHeader("$name: $value", true); + } else { + $code = array_shift($shift = explode(' ', $value)); + safeHeader("$name: $value", true, $code); + } + } + + if (!empty($this->_Headers['Cache-Control'])) { + \Vanilla\Web\CacheControlMiddleware::sendCacheControlHeaders($this->_Headers['Cache-Control']); + } + + // FIX: https://github.com/topcoder-platform/forums/issues/381 + if (class_exists('Tideways\Profiler')) { + safeHeader("Server-Timing: ".\Tideways\Profiler::generateServerTimingHeaderValue(), true); + } + + // Empty the collection after sending + $this->_Headers = []; + } + + /** + * Allows the adding of page header information that will be delivered to + * the browser before rendering. + * + * @param string $name The name of the header to send to the browser. + * @param string $value The value of the header to send to the browser. + */ + public function setHeader($name, $value) { + $this->_Headers[$name] = $value; + } + + /** + * Set data from a method call. + * + * If $key is an array, the behaviour will be the same as calling the method + * multiple times for each (key, value) pair in the $key array. + * Note that the parameter $value will not be used if $key is an array. + * + * The $key can also use dot notation in order to set a value deeper inside the Data array. + * Works the same way if $addProperty is true, but uses objects instead of arrays. + * + * @see setvalr + * + * @param string|array $key The key that identifies the data. + * @param mixed $value The data. Will not be used if $key is an array + * @param mixed $addProperty Whether or not to also set the data as a property of this object. + * @return mixed The $Value that was set. + */ + public function setData($key, $value = null, $addProperty = false) { + // In the case of $key being an array of (key => value), + // it calls itself with each (key => value) + if (is_array($key)) { + foreach ($key as $k => $v) { + $this->setData($k, $v, $addProperty); + } + return; + } + + setvalr($key, $this->Data, $value); + + if ($addProperty === true) { + setvalr($key, $this, $value); + } + return $value; + } + + /** + * Set $this->_FormSaved for JSON Renders. + * + * @param bool $saved Whether form data was successfully saved. + */ + public function setFormSaved($saved = true) { + if ($saved === '') { // Allow reset + $this->_FormSaved = ''; + } else { // Force true/false + $this->_FormSaved = ($saved) ? true : false; + } + } + + /** + * Looks for a Last-Modified header from the browser and compares it to the + * supplied date. If the Last-Modified date is after the supplied date, the + * controller will send a "304 Not Modified" response code to the web + * browser and stop all execution. Otherwise it sets the Last-Modified + * header for this page and continues processing. + * + * @param string $lastModifiedDate A unix timestamp representing the date that the current page was last + * modified. + */ + public function setLastModified($lastModifiedDate) { + $gMD = gmdate('D, d M Y H:i:s', $lastModifiedDate).' GMT'; + $this->setHeader('Etag', '"'.$gMD.'"'); + $this->setHeader('Last-Modified', $gMD); + $incomingHeaders = getallheaders(); + if (isset($incomingHeaders['If-Modified-Since']) + && isset ($incomingHeaders['If-None-Match']) + ) { + $ifNoneMatch = $incomingHeaders['If-None-Match']; + $ifModifiedSince = $incomingHeaders['If-Modified-Since']; + if ($gMD == $ifNoneMatch && $ifModifiedSince == $gMD) { + $database = Gdn::database(); + if (!is_null($database)) { + $database->closeConnection(); + } + + $this->setHeader('Content-Length', '0'); + $this->sendHeaders(); + safeHeader('HTTP/1.1 304 Not Modified'); + exit("\n\n"); // Send two linefeeds so that the client knows the response is complete + } + } + } + + /** + * If JSON is going to be sent to the client, this method allows you to add + * extra values to the JSON array. + * + * @param string $key The name of the array key to add. + * @param string $value The value to be added. If empty, nothing will be added. + */ + public function setJson($key, $value = '') { + $this->_Json[$key] = $value; + } + + /** + * + * + * @param $statusCode + * @param null $message + * @param bool $setHeader + * @return null|string + */ + public function statusCode($statusCode, $message = null, $setHeader = true) { + if (is_null($message)) { + $message = self::getStatusMessage($statusCode); + } + + if ($setHeader) { + $this->setHeader('Status', "{$statusCode} {$message}"); + } + return $message; + } + + /** + * + * + * @param $statusCode + * @return string + */ + public static function getStatusMessage($statusCode) { + switch ($statusCode) { + case 100: + $message = 'Continue'; + break; + case 101: + $message = 'Switching Protocols'; + break; + + case 200: + $message = 'OK'; + break; + case 201: + $message = 'Created'; + break; + case 202: + $message = 'Accepted'; + break; + case 203: + $message = 'Non-Authoritative Information'; + break; + case 204: + $message = 'No Content'; + break; + case 205: + $message = 'Reset Content'; + break; + + case 300: + $message = 'Multiple Choices'; + break; + case 301: + $message = 'Moved Permanently'; + break; + case 302: + $message = 'Found'; + break; + case 303: + $message = 'See Other'; + break; + case 304: + $message = 'Not Modified'; + break; + case 305: + $message = 'Use Proxy'; + break; + case 307: + $message = 'Temporary Redirect'; + break; + + case 400: + $message = 'Bad Request'; + break; + case 401: + $message = 'Not Authorized'; + break; + case 402: + $message = 'Payment Required'; + break; + case 403: + $message = 'Forbidden'; + break; + case 404: + $message = 'Not Found'; + break; + case 405: + $message = 'Method Not Allowed'; + break; + case 406: + $message = 'Not Acceptable'; + break; + case 407: + $message = 'Proxy Authentication Required'; + break; + case 408: + $message = 'Request Timeout'; + break; + case 409: + $message = 'Conflict'; + break; + case 410: + $message = 'Gone'; + break; + case 411: + $message = 'Length Required'; + break; + case 412: + $message = 'Precondition Failed'; + break; + case 413: + $message = 'Request Entity Too Large'; + break; + case 414: + $message = 'Request-URI Too Long'; + break; + case 415: + $message = 'Unsupported Media Type'; + break; + case 416: + $message = 'Requested Range Not Satisfiable'; + break; + case 417: + $message = 'Expectation Failed'; + break; + + case 500: + $message = 'Internal Server Error'; + break; + case 501: + $message = 'Not Implemented'; + break; + case 502: + $message = 'Bad Gateway'; + break; + case 503: + $message = 'Service Unavailable'; + break; + case 504: + $message = 'Gateway Timeout'; + break; + case 505: + $message = 'HTTP Version Not Supported'; + break; + + default: + $message = 'Unknown'; + break; + } + return $message; + } + + /** + * If this object has a "Head" object as a property, this will set it's Title value. + * + * @param string $title The value to pass to $this->Head->title(). + */ + public function title($title = null, $subtitle = null) { + if (!is_null($title)) { + $this->setData('Title', $title); + } + + if (!is_null($subtitle)) { + $this->setData('_Subtitle', $subtitle); + } + + return $this->data('Title'); + } + + /** + * Set the destination URL where the page will be redirected after an ajax request. + * + * @param string|null $destination Destination URL or path. + * Redirect to current URL if nothing or null is supplied. + * @param bool $trustedOnly Non trusted destinations will be redirected to /home/leaving?Target=$destination + */ + public function setRedirectTo($destination = null, $trustedOnly = true) { + if ($destination === null) { + $url = url(''); + } elseif ($trustedOnly) { + $url = safeURL($destination); + } else { + $url = url($destination); + } + + $this->redirectTo = $url; + $this->RedirectUrl = $url; + } +}