diff --git a/.circleci/config.yml b/.circleci/config.yml index 4a6aba4..1f62867 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -29,7 +29,7 @@ builddeploy_steps: &builddeploy_steps - run: *install_dependency - run: *install_deploysuite #- restore_cache: *restore_cache_settings_for_build - - run: ./build.sh ${APPNAME} ${CI_DEPLOY_TOKEN} ${LOGICAL_ENV} + - run: ./build.sh ${APPNAME} ${CI_DEPLOY_TOKEN} ${LOGICAL_ENV} ${BRANCH} #- save_cache: *save_cache_settings - deploy: name: Running MasterScript. @@ -43,6 +43,7 @@ builddeploy_steps: &builddeploy_steps jobs: # Build & Deploy against development backend + # 'BRANCH' is used for plugins and other dependency repos "build-dev": <<: *defaults environment: @@ -50,6 +51,7 @@ jobs: LOGICAL_ENV: "dev" APPNAME: "vanilla-forums" CI_DEPLOY_TOKEN: $CI_DEPLOY_TOKEN + BRANCH: "develop" steps: *builddeploy_steps "build-prod": @@ -59,6 +61,7 @@ jobs: LOGICAL_ENV: "prod" APPNAME: "vanilla-forums" CI_DEPLOY_TOKEN: $CI_DEPLOY_TOKEN + BRANCH: "master" steps: *builddeploy_steps workflows: diff --git a/Dockerfile b/Dockerfile index 51b07a8..751498b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,9 @@ FROM webdevops/php-apache ARG CI_DEPLOY_TOKEN ARG VANILLA_VERSION=3.3 ARG ENV +ARG BRANCH +ENV TIDEWAYS_SERVICE vanilla ENV WEB_DOCUMENT_ROOT /vanillapp # Get the latest release of Vanilla Forums @@ -14,33 +16,42 @@ RUN chmod -R 777 /vanillapp # Delete the auto-enabled 'stubcontent' plugin which adds stub contents RUN rm -R /vanillapp/plugins/stubcontent + +RUN echo "'$BRANCH' branch will be used for dependency repos ..." + # Clone the forum-plugins repository -RUN git clone https://github.com/topcoder-platform/forums-plugins.git /tmp/forums-plugins +RUN git clone --branch ${BRANCH} https://${CI_DEPLOY_TOKEN}@github.com/topcoder-platform/forums-plugins.git /tmp/forums-plugins + +# Copy the Filestack plugin +RUN git clone --branch ${BRANCH} https://${CI_DEPLOY_TOKEN}@github.com/topcoder-platform/forums-filestack-plugin /tmp/forums-plugins/Filestack + +# Copy the Groups plugin +RUN git clone --branch ${BRANCH} https://${CI_DEPLOY_TOKEN}@github.com/topcoder-platform/forums-groups-plugin /tmp/forums-plugins/Groups + +# Copy the SumoLogic plugin +RUN git clone --branch ${BRANCH} https://${CI_DEPLOY_TOKEN}@github.com/topcoder-platform/forums-sumologic-plugin /tmp/forums-plugins/Sumologic + +# Copy the TopcoderEditor plugin +RUN git clone --branch ${BRANCH} https://${CI_DEPLOY_TOKEN}@github.com/topcoder-platform/forums-topcoder-editor-plugin /tmp/forums-plugins/TopcoderEditor + +# Copy the forum-theme repository +RUN git clone --branch ${BRANCH} https://${CI_DEPLOY_TOKEN}@github.com/topcoder-platform/forums-theme.git /vanillapp/themes/topcoder # Remove DebugPlugin from PROD env # RUN if [ "$ENV" = "prod" ]; \ # then rm -R /tmp/forums-plugins/DebugPlugin; \ # fi -# Copy the Filestack plugin -RUN git clone https://${CI_DEPLOY_TOKEN}@github.com/topcoder-platform/forums-filestack-plugin /tmp/forums-plugins/Filestack - -#Copy the Groups plugin -RUN git clone https://${CI_DEPLOY_TOKEN}@github.com/topcoder-platform/forums-groups-plugin /tmp/forums-plugins/Groups - -#Copy the SumoLogic plugin -RUN git clone https://${CI_DEPLOY_TOKEN}@github.com/topcoder-platform/forums-sumologic-plugin /tmp/forums-plugins/Sumologic - -#Copy the TopcoderEditor plugin -RUN git clone https://${CI_DEPLOY_TOKEN}@github.com/topcoder-platform/forums-topcoder-editor-plugin /tmp/forums-plugins/TopcoderEditor # Copy all plugins to the Vanilla plugins folder RUN cp -r /tmp/forums-plugins/. /vanillapp/plugins -#Get the debug bar plugin -RUN wget https://us.v-cdn.net/5018160/uploads/addons/KSBIPJYMC0F2.zip -RUN unzip KSBIPJYMC0F2.zip -RUN cp -r debugbar /vanillapp/plugins +# Get the debug bar plugin +RUN if [ "$ENV" = "dev" ]; then \ + wget https://us.v-cdn.net/5018160/uploads/addons/KSBIPJYMC0F2.zip; \ + unzip KSBIPJYMC0F2.zip; \ + cp -r debugbar /vanillapp/plugins; \ +fi # Install Topcoder dependencies RUN composer install --working-dir /vanillapp/plugins/Topcoder @@ -57,5 +68,17 @@ COPY ./vanilla/. /vanillapp/. # Set permissions on config file RUN chown application:application /vanillapp/conf/config.php RUN chmod ug=rwx,o=rx /vanillapp/conf/config.php -# Clone the forum-theme repository -RUN git clone 'https://github.com/topcoder-platform/forums-theme.git' /vanillapp/themes/topcoder + + +# Tideways +RUN if [ "$ENV" = "dev" ]; then \ + apt-get update && apt-get install -y gnupg2; \ + echo 'deb https://packages.tideways.com/apt-packages debian main' > /etc/apt/sources.list.d/tideways.list && \ + curl -L -sS 'https://packages.tideways.com/key.gpg' | apt-key add - && \ + apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get -yq install tideways-php && \ + apt-get autoremove --assume-yes && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*; \ + echo 'extension=tideways.so\ntideways.connection=tcp://tideways-daemon:9135\ntideways.enable_cli=0\n' >> opt/docker/etc/php/php.ini; \ +fi \ No newline at end of file diff --git a/build.sh b/build.sh index ffd7705..e764dbc 100755 --- a/build.sh +++ b/build.sh @@ -3,7 +3,12 @@ set -eo pipefail APP_NAME=$1 CI_DEPLOY_TOKEN=$2 ENV=$3 +BRANCH=$4 UPDATE_CACHE="" echo "" > vanilla.env -ENV=$ENV CI_DEPLOY_TOKEN=$CI_DEPLOY_TOKEN docker-compose -f docker-compose.yml build $APP_NAME +if [ "$ENV" = "dev" ]; then + ENV=$ENV CI_DEPLOY_TOKEN=$CI_DEPLOY_TOKEN BRANCH=$BRANCH docker-compose -f docker-compose.yml -f docker-compose.dev.yml build $APP_NAME +else + ENV=$ENV CI_DEPLOY_TOKEN=$CI_DEPLOY_TOKEN BRANCH=$BRANCH docker-compose -f docker-compose.yml build $APP_NAME +fi #docker create --name app $APP_NAME:latest \ No newline at end of file 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/config/vanilla/bootstrap.early.php b/config/vanilla/bootstrap.early.php index ee4cf65..cb4051b 100644 --- a/config/vanilla/bootstrap.early.php +++ b/config/vanilla/bootstrap.early.php @@ -137,4 +137,19 @@ Gdn::sql()->query($emptyAncestorQuery); } + // FIX: https://github.com/topcoder-platform/forums/issues/449 + if(!Gdn::structure()->tableExists('GroupInvitation')) { + // Group Invitation Table + Gdn::structure()->table('GroupInvitation') + ->primaryKey('GroupInvitationID') + ->column('GroupID', 'int', false, 'index') + ->column('Token', 'varchar(32)', false, 'unique') + ->column('InvitedByUserID', 'int', false, 'index') + ->column('InviteeUserID', 'int', false, 'index') + ->column('DateInserted', 'datetime', false, 'index') + ->column('Status', ['pending', 'accepted', 'declined', 'deleted']) + ->column('DateAccepted', 'datetime', true) + ->column('DateExpires', 'datetime') + ->set(false, false); + } } \ No newline at end of file diff --git a/config/vanilla/config.php b/config/vanilla/config.php index f13d0b8..731eb9f 100644 --- a/config/vanilla/config.php +++ b/config/vanilla/config.php @@ -113,6 +113,8 @@ $Configuration['Plugins']['Sumologic']['HttpSourceURL'] = ''; $Configuration['Plugins']['Sumologic']['BatchSize'] = 10; +// e.g. '+15 min', '+1 day' +$Configuration['Plugins']['Groups']['InviteExpiration']= '+20 min'; // RichEditor $Configuration['RichEditor']['Quote']['Enable'] = true; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 579cc98..7af0d71 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,21 +1,11 @@ version: '3' services: - mysql-local: - image: mysql - container_name: mysql-local - ports: - - 3306:3306 - env_file: - - ./mysql.env - security_opt: - - seccomp:unconfined - command: --default-authentication-plugin=mysql_native_password vanilla-forums: links: - - mysql-local - - memcached-local - memcached-local: - image: memcached:1.5 - container_name: memcached-local + - tideways-daemon + tideways-daemon: + container_name: tideways-daemon + build: + context: ./tideways-daemon ports: - - "11211:11211" \ No newline at end of file + - 9135:9135 \ No newline at end of file diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..579cc98 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,21 @@ +version: '3' +services: + mysql-local: + image: mysql + container_name: mysql-local + ports: + - 3306:3306 + env_file: + - ./mysql.env + security_opt: + - seccomp:unconfined + command: --default-authentication-plugin=mysql_native_password + vanilla-forums: + links: + - mysql-local + - memcached-local + memcached-local: + image: memcached:1.5 + container_name: memcached-local + ports: + - "11211:11211" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 7af8fc1..43dab53 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,5 +11,6 @@ services: - ENV - VANILLA_VERSION=3.3 - CI_DEPLOY_TOKEN + - BRANCH ports: - 80:80 \ No newline at end of file diff --git a/tideways-daemon/Dockerfile b/tideways-daemon/Dockerfile new file mode 100644 index 0000000..3918cf7 --- /dev/null +++ b/tideways-daemon/Dockerfile @@ -0,0 +1,20 @@ +FROM debian:stable-slim + +ARG TIDEWAYS_ENVIRONMENT_DEFAULT=production +ENV TIDEWAYS_ENVIRONMENT=$TIDEWAYS_ENVIRONMENT_DEFAULT + +RUN useradd --system tideways +RUN apt-get update && apt-get install -yq --no-install-recommends gnupg2 curl sudo ca-certificates + +RUN echo 'deb https://packages.tideways.com/apt-packages debian main' > /etc/apt/sources.list.d/tideways.list && \ + curl -L -sS 'https://packages.tideways.com/key.gpg' | apt-key add - +RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -yq tideways-daemon && \ + apt-get autoremove --assume-yes && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +EXPOSE 9135 + +USER tideways + +ENTRYPOINT ["tideways-daemon","--hostname=tideways-daemon","--address=0.0.0.0:9135"] \ No newline at end of file diff --git a/vanilla/applications/dashboard/library/class.nestedcollection.php b/vanilla/applications/dashboard/library/class.nestedcollection.php new file mode 100644 index 0000000..5980805 --- /dev/null +++ b/vanilla/applications/dashboard/library/class.nestedcollection.php @@ -0,0 +1,764 @@ + + * @copyright 2015 Vanilla Forums, Inc + * @license http://www.opensource.org/licenses/gpl-2.0.php GPL + * @since 2.3 + */ + +trait NestedCollection { + + /** + * @var string The css class to add to active items and groups. + */ + private $activeCssClass = 'active'; + + /** + * @var array List of items to sort. + */ + private $items = []; + + /** + * @var int Index number to start the item* key-generation with. + */ + private $keyNumber = 1; + + /** + * @var bool Whether to use CSS prefixes on the generated CSS classes for the items. + */ + private $useCssPrefix = false; + + /** + * @var string CSS prefix for a header item. + */ + private $headerCssClassPrefix = 'header'; + + /** + * @var string CSS prefix for a link item. + */ + private $linkCssClassPrefix = 'link'; + + /** + * @var string CSS prefix for a divider item. + */ + private $dividerCssClassPrefix = 'divider'; + + /** + * @var bool Whether to flatten the list (as with a dropdown menu) or allow nesting (as with a nav). + */ + private $flatten = false; + + /** + * @var bool Whether to separate groups with a hr element. Only supported for flattened lists. + */ + private $forceDivider = true; + + /** + * @var array The allowed keys in the $modifiers array parameter in the 'addItem' methods. + */ + private $isPrepared = false; + + /** + * @var string The url to the display as active. + */ + private $highlightRoute = ''; + + /** + * @var array The item modifiers allowed to be passed in the modifiers array. + */ + private $allowedItemModifiers = ['popinRel', 'icon', 'badge', 'rel', 'description', 'attributes', 'listItemCssClasses']; + + /** + * @param boolean $forceDivider Whether to separate groups with a
element. Only supported for flattened lists. + * @return $this + */ + public function setForceDivider($forceDivider) { + $this->forceDivider = $forceDivider; + return $this; + } + + /** + * @return string + */ + public function getActiveCssClass() { + return $this->activeCssClass; + } + + /** + * @param string $activeCssClass + * @return $this + */ + public function setActiveCssClass($activeCssClass) { + $this->activeCssClass = $activeCssClass; + return $this; + } + + /** + * @param boolean $useCssPrefix + * @return $this + */ + public function useCssPrefix($useCssPrefix) { + $this->useCssPrefix = $useCssPrefix; + return $this; + } + + /** + * @return string + */ + public function getHeaderCssClassPrefix() { + return $this->headerCssClassPrefix; + } + + /** + * @param string $headerCssClassPrefix + * @return $this + */ + public function setHeaderCssClassPrefix($headerCssClassPrefix) { + $this->headerCssClassPrefix = $headerCssClassPrefix; + return $this; + } + + /** + * @return string + */ + public function getLinkCssClassPrefix() { + return $this->linkCssClassPrefix; + } + + /** + * @param string $linkCssClassPrefix + * @return $this + */ + public function setLinkCssClassPrefix($linkCssClassPrefix) { + $this->linkCssClassPrefix = $linkCssClassPrefix; + return $this; + } + + /** + * @return string + */ + public function getDividerCssClassPrefix() { + return $this->dividerCssClassPrefix; + } + + /** + * @param string $dividerCssClassPrefix + * @return $this + */ + public function setDividerCssClassPrefix($dividerCssClassPrefix) { + $this->dividerCssClassPrefix = $dividerCssClassPrefix; + return $this; + } + + /** + * @param boolean $flatten + * @return $this + */ + public function setFlatten($flatten) { + $this->flatten = $flatten; + return $this; + } + + /** + * @return string + */ + public function getHighlightRoute() { + return $this->highlightRoute; + } + + /** + * @param string $highlightRoute + * @return $this + */ + public function setHighlightRoute($highlightRoute) { + $this->highlightRoute = $highlightRoute; + return $this; + } + + /** + * @return array + */ + public function getItems() { + return $this->items; + } + + /** + * @param array $items + * @return $this + */ + public function setItems($items) { + $this->items = $items; + return $this; + } + + /** + * Add a divider to the items array if it satisfies the $isAllowed condition. + * + * @param bool|string|array $isAllowed Either a boolean to indicate whether to actually add the item + * or a permission string or array of permission strings (full match) to check. + * @param string $key The item's key (for sorting and CSS targeting). + * @param array|int $sort Either a numeric sort position or and array in the style: array('before|after', 'key'). + * @param string $cssClass The divider's CSS class. + * @return object $this The calling object. + * @throws Exception + */ + public function addDividerIf($isAllowed = true, $key = '', $cssClass = '', $sort = []) { + if (!$this->isAllowed($isAllowed)) { + return $this; + } else { + return $this->addDivider($key, $cssClass, $sort); + } + } + + /** + * Add a divider to the items array. + * + * @param string $key The item's key (for sorting and CSS targeting). + * @param array|int $sort Either a numeric sort position or and array in the style: array('before|after', 'key'). + * @param string $cssClass The divider's CSS class. + * @return object $this The calling object. + * @throws Exception + */ + public function addDivider($key = '', $cssClass = '', $sort = []) { + $divider = ['key' => $key]; + if ($sort) { + $divider['sort'] = $sort; + } + + $this->touchKey($divider); + $divider['cssClass'] = $cssClass.' '.$this->buildCssClass($this->dividerCssClassPrefix, $divider); + + $this->addItem('divider', $divider); + return $this; + } + + /** + * Add a group to the items array if it satisfies the $isAllowed condition. + * + * @param bool|string|array $isAllowed Either a boolean to indicate whether to actually add the item + * or a permission string or array of permission strings (full match) to check. + * @param string $text The display text for the group header. + * @param string $key The item's key (for sorting and CSS targeting). + * @param string $cssClass The group header's CSS class. + * @param array|int $sort Either a numeric sort position or and array in the style: array('before|after', 'key'). + * @param array $modifiers List of attribute => value, where the attribute is in $this->allowedItemModifiers. + * - **popinRel**: string - Endpoint for a popin. + * - **badge**: string - Info to put into a badge, usually a number. + * - **icon**: string - Name of the icon for the item, excluding the 'icon-' prefix. + * @return object $this The calling object. + * @throws Exception + */ + public function addGroupIf($isAllowed = true, $text = '', $key = '', $cssClass = '', $sort = [], $modifiers = []) { + if (!$this->isAllowed($isAllowed)) { + return $this; + } else { + return $this->addGroup($text, $key, $cssClass, $sort, $modifiers); + } + } + + + /** + * Checks whether an item can be added to the items list by returning it if it is already a boolean, + * or checking the permission if it is a string or array. + * + * @param bool|string|array $isAllowed Either a boolean to indicate whether to actually add the item + * or a permission string or array of permission strings (full match) to check. + * @return bool Whether the item has permission to be added to the items list. + */ + protected function isAllowed($isAllowed) { + if (is_bool($isAllowed)) { + return $isAllowed; + } + if (is_string($isAllowed) || is_array($isAllowed)) { + return Gdn::session()->checkPermission($isAllowed); + } + return false; + } + + /** + * Add a group to the items array. + * + * @param string $text The display text for the group header. + * @param string $key The item's key (for sorting and CSS targeting). + * @param string $cssClass The group header's CSS class. + * @param array|int $sort Either a numeric sort position or and array in the style: array('before|after', 'key'). + * @param array $modifiers List of attribute => value, where the attribute is in $this->allowedItemModifiers. + * - **popinRel**: string - Endpoint for a popin. + * - **badge**: string - Info to put into a badge, usually a number. + * - **icon**: string - Name of the icon for the item, excluding the 'icon-' prefix. + * @return SortableModule $this The calling object. + * @throws Exception + */ + public function addGroup($text = '', $key = '', $cssClass = '', $sort = [], $modifiers = []) { + $group = [ + 'text' => $text, + 'key' => $key, + 'cssClass' => $cssClass + ]; + + if ($sort) { + $group['sort'] = $sort; + } + + if (!empty($modifiers)) { + $this->addItemModifiers($group, $modifiers); + } + + $this->touchKey($group); + + if ($text) { + $group['headerCssClass'] = $cssClass.' '.$this->buildCssClass($this->headerCssClassPrefix, $group); + } + $this->addItem('group', $group); + return $this; + } + + /** + * Add a link to the items array if it satisfies the $isAllowed condition. + * + * @param bool|string|array $isAllowed Either a boolean to indicate whether to actually add the item + * or a permission string or array of permission strings (full match) to check. + * @param string $text The display text for the link. + * @param string $url The destination url for the link. + * @param string $key The item's key (for sorting and CSS targeting). + * @param string $cssClass The link's CSS class. + * @param array|int $sort Either a numeric sort position or and array in the style: array('before|after', 'key'). + * @param array $modifiers List of attribute => value, where the attribute is in $this->allowedItemModifiers. + * - **popinRel**: string - Endpoint for a popin. + * - **badge**: string - Info to put into a badge, usually a number. + * - **icon**: string - Name of the icon for the item, excluding the 'icon-' prefix. + * @param bool $disabled Whether to disable the link. + * @return object $this The calling object. + */ + public function addLinkIf($isAllowed = true, $text, $url, $key = '', $cssClass = '', $sort = [], $modifiers = [], $disabled = false) { + if (!$this->isAllowed($isAllowed)) { + return $this; + } else { + return $this->addLink($text, $url, $key, $cssClass, $sort, $modifiers, $disabled); + } + } + + /** + * Add a link to the items array. + * + * @param string $text The display text for the link. + * @param string $url The destination url for the link. + * @param string $key The item's key (for sorting and CSS targeting). + * @param string $cssClass The link's CSS class. + * @param array|int $sort Either a numeric sort position or and array in the style: array('before|after', 'key'). + * @param array $modifiers List of attribute => value, where the attribute is in $this->allowedItemModifiers. + * - **popinRel**: string - Endpoint for a popin. + * - **badge**: string - Info to put into a badge, usually a number. + * - **icon**: string - Name of the icon for the item, excluding the 'icon-' prefix. + * - **listItemCssClasses**: array - Array of class names to be applied to the list item. + * @param bool $disabled Whether to disable the link. + * @return $this The calling object. + * @throws Exception + */ + public function addLink($text, $url, $key = '', $cssClass = '', $sort = [], $modifiers = [], $disabled = false) { + $link = [ + 'text' => $text, + 'url' => $url, + 'key' => $key, + ]; + + if ($sort) { + $link['sort'] = $sort; + } + + if (!empty($modifiers)) { + $this->addItemModifiers($link, $modifiers); + } + + $this->touchKey($link); + $link['cssClass'] = $cssClass.' '.$this->buildCssClass($this->linkCssClassPrefix, $link); + + $listItemCssClasses = $modifiers['listItemCssClasses'] ?? []; + if ($disabled) { + $listItemCssClasses[] = 'disabled'; + } + if ($this->isActive($link)) { + $link['isActive'] = true; + $listItemCssClasses[] = $this->activeCssClass; + } else { + $link['isActive'] = false; + } + + $link['listItemCssClass'] = implode(' ', $listItemCssClasses); + $this->addItem('link', $link); + return $this; + } + + public function addText($text, $key = '', $cssClass = '', $sort = [], $modifiers = []) { + $link = [ + 'text' => $text, + 'key' => $key, + ]; + + if ($sort) { + $link['sort'] = $sort; + } + + if (!empty($modifiers)) { + $this->addItemModifiers($link, $modifiers); + } + + $this->touchKey($link); + $link['cssClass'] = $cssClass.' '.$this->buildCssClass($this->linkCssClassPrefix, $link); + + $listItemCssClasses = $modifiers['listItemCssClasses'] ?? []; + + $link['listItemCssClass'] = implode(' ', $listItemCssClasses); + $this->addItem('link', $link); + return $this; + } + + /** + * Adds the attributes in the modifiers array to the item. + * Constrains the modifier to those defined in $this->allowedItemModifiers. + * + * @param array $item The item to modify. + * @param array $modifiers The modifiers to add to the item. + */ + private function addItemModifiers(&$item, $modifiers) { + $modifiers = array_intersect_key($modifiers, array_flip($this->allowedItemModifiers)); + foreach ($modifiers as $attribute => $value) { + $item[$attribute] = $value; + } + } + + /** + * Generate a key for an item if one does not exist, and add the property to the item. + * + * @param array $item The item to generate and add a key for. + */ + private function touchKey(&$item) { + if (!val('key', $item)) { + $item['key'] = 'item'.$this->keyNumber; + $this->keyNumber = $this->keyNumber + 1; + } + } + + /** + * Add an item to the items array. + * + * @param string $type The type of the item: link, group or divider. + * @param array $item The item to add to the array. + * @throws Exception + */ + private function addItem($type, array $item) { + $this->touchKey($item); + $key = $item['key'] ?? false; + if (!is_array($key)) { + $item['key'] = explode('.', $key); + } else { + $item['key'] = array_values($key); + } + + $item = (array)$item; + + // Make sure the link has its type. + $item['type'] = $type; + + // Walk into the items list to set the item. + $items =& $this->items; + foreach ($item['key'] as $i => $key_part) { + + if ($i === count($item['key'] ?? false) - 1) { + // Add the item here. + if (array_key_exists($key_part, $items)) { + // The item is already here so merge this one on top of it. + if ($items[$key_part]['type'] !== $type) + throw new \Exception(($item['key'] ?? '')." of type $type does not match existing type {$items[$key_part]['type']}.", 500); + + $items[$key_part] = array_merge($items[$key_part], $item); + } else { + // The item is new so just add it here. + touchValue('_sort', $item, count($items)); + $items[$key_part] = $item; + } + } else { + // This is a group. + if (!array_key_exists($key_part, $items)) { + // The group doesn't exist so lazy-create it. + $items[$key_part] = ['type' => 'group', 'text' => '', 'items' => [], '_sort' => count($items)]; + } elseif ($items[$key_part]['type'] !== 'group') { + throw new \Exception("$key_part is not a group", 500); + } elseif (!array_key_exists('items', $items[$key_part])) { + // Lazy create the items array. + $items[$key_part]['items'] = []; + } + $items =& $items[$key_part]['items']; + } + } + } + + /** + * Remove an item from the nested set. + * + * @param string $key The key of the item to remove, separated by dots. + */ + public function removeItem($key) { + $parts = explode('.', $key); + + $arr = &$this->items; + foreach ($parts as $i => $part) { + if (array_key_exists($part, $arr)) { + if ($i + 1 === count($parts)) { + unset($arr[$part]); + } else { + $arr = &$arr[$part]; + } + } else { + // The key wasn't found so short circuit. + return; + } + } + } + + /** + * Builds a CSS class for an item, based on the 'key' property of the item. + * Optionally prepends a prefix to generated class names. + * + * @param string $prefix The optional prefix to add to class name. + * @param array $item The item to generate CSS class for. + * @return string The generated CSS class. + */ + private function buildCssClass($prefix, array $item = []) { + $result = ''; + if ($prefix) { + $prefix .= '-'; + } + if (!$this->useCssPrefix) { + $prefix = ''; + } + if ($cssClass = ($item['key'] ?? false)) { + if (is_array($cssClass)) { + $result .= $prefix.implode('-', $cssClass); + } + else { + $result .= $prefix.str_replace('.', '-', $cssClass); + } + } + return trim($result); + } + + /** + * Checks whether the current request url matches an item's link url. + * + * @param array $item The item to check. + * @return bool Whether the current request url matches an item's link url. + */ + private function isActive($item) { + if (empty($this->highlightRoute)) { + $highlightRoute = Gdn_Url::request(true); + } else { + $highlightRoute = url($this->highlightRoute); + } + $url = $item['url'] ?? false; + if ($url) { + $result = trim(url($url), '/') == trim($highlightRoute, '/'); + } else { + $result = false; + } + return $result; + } + + /** + * Recursive function to sort the items in a given array. + * + * @param array &$items The items to sort. + */ + protected function sortItems(&$items) { + $i = 0; + foreach ($items as &$item) { + $item += ['_sort' => $i++]; + if ($item['items'] ?? false) { + $this->sortItems($item['items']); + } + } + + uasort($items, function ($a, $b) use ($items) { + $sort_a = $this->sortItemsOrder($a, $items); + $sort_b = $this->sortItemsOrder($b, $items); + + if ($sort_a > $sort_b) { + return 1; + } elseif ($sort_a < $sort_b) { + return -1; + } else { + return 0; + } + }); + } + + /** + * Get the sort order of an item in the items array. + * + * This function looks at the following keys: + * - **sort (numeric)**: A specific numeric sort was provided. + * - **sort array('before|after', 'key')**: You can specify that the item is before or after another item. + * - **_sort**: The order the item was added is used. + * + * @param array $item The item to get the sort order from. + * @param array $items The entire list of items. + * @param int $depth The current recursive depth used to prevent infinite recursion. + * @return number + */ + private function sortItemsOrder($item, $items, $depth = 0) { + $default_sort = val('_sort', $item, 100); + + // Check to see if a custom sort has been specified. + if (isset($item['sort'])) { + if (is_numeric($item['sort'])) { + // This is a numeric sort + return $item['sort'] * 10000 + $default_sort; + } elseif (is_array($item['sort']) && !empty($item['sort']) && $depth < 10) { + // This sort is before or after another depth. + $op = array_keys($item['sort'])[0]; + $key = $item['sort'][$op]; + + if (array_key_exists($key, $items)) { + switch ($op) { + case 'after': + return $this->sortItemsOrder($items[$key], $items, $depth + 1) + 1000; + case 'before': + default: + return $this->sortItemsOrder($items[$key], $items, $depth + 1) - 1000; + } + } + } + } + return $default_sort * 10000 + $default_sort; + } + + /** + * Prepares the items array for output by sorting and optionally flattening. + * + * @return bool Whether to render the module. + */ + public function prepare() { + if ($this->isPrepared) { + return !empty($this->items); + } + $this->isPrepared = true; + $this->sortItems($this->items); + $this->prepareData($this->items); + if ($this->flatten) { + $this->items = $this->flattenArray($this->items); + } + return !empty($this->items); + } + + /** + * Performs post-sort operations to the items array. + * Removes empty groups, removes the '_sort' and 'key' attributes and bubbles up the active css class. + * + * @param array $items The item list to parse. + */ + private function prepareData(&$items) { + foreach($items as $key => &$item) { + unset($item['_sort'], $item['key']); + $subItems = false; + + // Group item + if (val('type', $item) === 'group') { + // ensure groups have items + if (val('items', $item)) { + $subItems = true; + } else { + unset($items[$key]); + } + } + + if ($subItems) { + $this->prepareData($item['items']); + // Set active state on parents if child has it + if (!$this->flatten) { + foreach ($item['items'] as $subItem) { + if (val('isActive', $subItem)) { + $item['isActive'] = true; + $item['cssClass'] .= ' '.$this->activeCssClass; + } + } + } + } + } + } + + /** + * Recursive utility function to support returning this object as an array. + * + * @param $obj The object to transform. + * @param array $blackList Blacklisted property names. + * @param array $whiteList Whitelisted property names. If set, only whitelisted properties will appear in the result. + * @return array An array transformation of this object. + */ + private function objectToArray($obj, array $blackList = [], array $whiteList = []) { + if (is_array($obj) || is_object($obj)) { + $result = []; + foreach ($obj as $key => $value) { + if (!in_array($key, $blackList) && (empty($whiteList) || in_array($key, $whiteList))) { + $result[$key] = $this->objectToArray($value); + } + } + return $result; + } + return $obj; + } + + /** + * Copies the object to an array. A simple (array) typecast won't work, + * since the properties are private and as such, add unwanted information to the array keys. + * + * @param array $blackList Blacklisted property names. + * @param array $whiteList Whitelisted property names. If set, only whitelisted properties will appear in the result. + * @return array Copy of this object in an array format. + */ + public function toArray(array $blackList = [], array $whiteList = []) { + $blackList[] = '_Sender'; + return $this->objectToArray($this, $blackList, $whiteList); + } + + /** + * Creates a flattened array of menu items. + * Useful for lists like dropdown menu, where nesting lists is not necessary. + * + * @param array $items The item list to flatten. + * @return array The flattened items list. + */ + private function flattenArray(array $items) { + $newItems = []; + $itemslength = sizeof($items); + $index = 0; + foreach($items as $key => $item) { + $subItems = []; + + // Group item + if (($item['type'] ?? '') == 'group') { + if (($item['items'] ?? false)) { + $subItems = $item['items']; + unset($item['items']); + if (($item['text'] ?? false)) { + $newItems[] = $item; + } + } + } + if (($item['type'] ?? '') != 'group') { + $newItems[] = $item; + } + if ($subItems) { + $newItems = array_merge($newItems, $this->flattenArray($subItems)); + if ($this->forceDivider && $index + 1 < $itemslength) { + // Add hr after group but not the last one + $newItems[] = ['type' => 'divider']; + } + } + $index++; + } + return $newItems; + } +} diff --git a/vanilla/applications/dashboard/models/class.activitymodel.php b/vanilla/applications/dashboard/models/class.activitymodel.php new file mode 100644 index 0000000..4bafd8d --- /dev/null +++ b/vanilla/applications/dashboard/models/class.activitymodel.php @@ -0,0 +1,1896 @@ +setPruneAfter(c('Garden.PruneActivityAfter', '2 months')); + } catch (Exception $ex) { + $this->setPruneAfter('2 months'); + } + } + + /** + * Build basis of common activity SQL query. + * + * @param bool $join + * @since 2.0.0 + * @access public + */ + public function activityQuery($join = true) { + $this->SQL + ->select('a.*') + ->select('t.FullHeadline, t.ProfileHeadline, t.AllowComments, t.ShowIcon, t.RouteCode') + ->select('t.Name', '', 'ActivityType') + ->from('Activity a') + ->join('ActivityType t', 'a.ActivityTypeID = t.ActivityTypeID'); + + if ($join) { + $this->SQL + ->select('au.Name', '', 'ActivityName') + ->select('au.Gender', '', 'ActivityGender') + ->select('au.Photo', '', 'ActivityPhoto') + ->select('au.Email', '', 'ActivityEmail') + ->select('ru.Name', '', 'RegardingName') + ->select('ru.Gender', '', 'RegardingGender') + ->select('ru.Email', '', 'RegardingEmail') + ->select('ru.Photo', '', 'RegardingPhoto') + ->join('User au', 'a.ActivityUserID = au.UserID') + ->join('User ru', 'a.RegardingUserID = ru.UserID', 'left'); + } + + $this->fireEvent('AfterActivityQuery'); + } + + /** + * Can the current user view the activity? + * + * @param array $activity + * @return bool + */ + public function canView(array $activity): bool { + $result = false; + + $userid = val('NotifyUserID', $activity); + switch ($userid) { + case ActivityModel::NOTIFY_PUBLIC: + $result = true; + break; + case ActivityModel::NOTIFY_MODS: + if (checkPermission('Garden.Moderation.Manage')) { + $result = true; + } + break; + case ActivityModel::NOTIFY_ADMINS: + if (checkPermission('Garden.Settings.Manage')) { + $result = true; + } + break; + default: + // Actual userid. + if (Gdn::session()->UserID === $userid || checkPermission('Garden.Community.Manage')) { + $result = true; + } + break; + } + + return $result; + } + + /** + * + * + * @param $data + */ + public function calculateData(&$data) { + foreach ($data as &$row) { + $this->calculateRow($row); + } + } + + /** + * + * + * @param $row + */ + public function calculateRow(&$row) { + $activityType = self::getActivityType($row['ActivityTypeID']); + $row['ActivityType'] = val('Name', $activityType); + if (is_string($row['Data'])) { + $row['Data'] = dbdecode($row['Data']); + } + + $row['PhotoUrl'] = url($row['Route'], true); + if (!$row['Photo']) { + if (isset($row['ActivityPhoto'])) { + $row['Photo'] = $row['ActivityPhoto']; + $row['PhotoUrl'] = userUrl($row, 'Activity'); + } else { + $user = Gdn::userModel()->getID($row['ActivityUserID'], DATASET_TYPE_ARRAY); + if ($user) { + $photo = $user['Photo']; + $row['PhotoUrl'] = userUrl($user); + if (!$photo || stringBeginsWith($photo, 'http')) { + $row['Photo'] = $photo; + } else { + $row['Photo'] = Gdn_Upload::url(changeBasename($photo, 'n%s')); + } + } + } + } + + $data = $row['Data']; + if (isset($data['ActivityUserIDs'])) { + $row['ActivityUserID'] = array_merge([$row['ActivityUserID']], $data['ActivityUserIDs']); + $row['ActivityUserID_Count'] = val('ActivityUserID_Count', $data); + } + + if (isset($data['RegardingUserIDs'])) { + $row['RegardingUserID'] = array_merge([$row['RegardingUserID']], $data['RegardingUserIDs']); + $row['RegardingUserID_Count'] = val('RegardingUserID_Count', $data); + } + + + if (!empty($row['Route'])) { + $row['Url'] = externalUrl($row['Route']); + } else { + $id = $row['ActivityID']; + $row['Url'] = Gdn::request()->url("/activity/item/$id", true); + } + + if ($row['HeadlineFormat']) { + $row['Headline'] = formatString($row['HeadlineFormat'], $row); + } else { + $row['Headline'] = Gdn_Format::activityHeadline($row); + } + } + + /** + * Define a new activity type. + * @param string $name The string code of the activity type. + * @param array $activity The data that goes in the ActivityType table. + * @since 2.1 + */ + public function defineType($name, $activity = []) { + $this->SQL->replace('ActivityType', $activity, ['Name' => $name], true); + } + + /** + * {@inheritdoc} + */ + public function delete($where = [], $options = []) { + if (is_numeric($where)) { + deprecated('ActivityModel->delete(int)', 'ActivityModel->deleteID(int)'); + + $result = $this->deleteID($where, $options); + return $result; + } elseif (count($where) === 1 && isset($where['ActivityID'])) { + return parent::delete($where, $options); + } + + throw new \BadMethodCallException("ActivityModel->delete() is not supported.", 400); + } + + /** + * Delete a particular activity item. + * + * @param int $activityID The unique ID of activity to be deleted. + * @param array $options Not used. + * @return bool Returns **true** if the activity was deleted or **false** otherwise. + */ + public function deleteID($activityID, $options = []) { + // Get the activity first. + $activity = $this->getID($activityID); + if ($activity) { + // Log the deletion. + $log = val('Log', $options); + if ($log) { + LogModel::insert($log, 'Activity', $activity); + } + + // Delete comments on the activity item + $this->SQL->delete('ActivityComment', ['ActivityID' => $activityID]); + + // Delete the activity item + return parent::deleteID($activityID); + } else { + return false; + } + } + + /** + * Delete an activity comment. + * + * @since 2.1 + * + * @param int $iD + * @return Gdn_DataSet + */ + public function deleteComment($iD) { + return $this->SQL->delete('ActivityComment', ['ActivityCommentID' => $iD]); + } + + /** + * Get the recent activities. + * + * @param array $where + * @param int $limit + * @param int $offset + * @return Gdn_DataSet + */ + public function getWhereRecent($where, $limit = 0, $offset = 0) { + $result = $this->getWhere($where, '', '', $limit, $offset); + return $result; + } + + /** + * Modifies standard Gdn_Model->GetWhere to use AcitivityQuery. + * + * Events: AfterGet. + * + * @param array $where A filter suitable for passing to Gdn_SQLDriver::where(). + * @param string $orderFields A comma delimited string to order the data. + * @param string $orderDirection One of **asc** or **desc**. + * @param int|bool $limit The database limit. + * @param int|bool $offset The database offset. + * @return Gdn_DataSet SQL results. + */ + public function getWhere($where = [], $orderFields = '', $orderDirection = '', $limit = false, $offset = false) { + if (is_string($where)) { + deprecated('ActivityModel->getWhere($key, $value)', 'ActivityModel->getWhere([$key => $value])'); + $where = [$where => $orderFields]; + $orderFields = ''; + } + if (is_numeric($orderFields)) { + deprecated('ActivityModel->getWhere($where, $limit)'); + $limit = $orderFields; + $orderFields = ''; + } + if (is_numeric($orderDirection)) { + deprecated('ActivityModel->getWhere($where, $limit, $offset)'); + $offset = $orderDirection; + $orderDirection = ''; + } + $limit = $limit ?: 30; + $offset = $offset ?: 0; + + $orderFields = $orderFields ?: 'a.DateUpdated'; + $orderDirection = $orderDirection ?: 'desc'; + + // Add the basic activity query. + $this->SQL + ->select('a2.*') + ->select('t.FullHeadline, t.ProfileHeadline, t.AllowComments, t.ShowIcon, t.RouteCode') + ->select('t.Name', '', 'ActivityType') + ->from('Activity a') + ->join('Activity a2', 'a.ActivityID = a2.ActivityID')// self-join for index speed. + ->join('ActivityType t', 'a2.ActivityTypeID = t.ActivityTypeID'); + + // Add prefixes to the where. + foreach ($where as $key => $value) { + if (strpos($key, '.') === false) { + $where['a.'.$key] = $value; + unset($where[$key]); + } + } + + $result = $this->SQL + ->where($where) + ->orderBy($orderFields, $orderDirection) + ->limit($limit, $offset) + ->get(); + + self::getUsers($result->resultArray()); + Gdn::userModel()->joinUsers( + $result->resultArray(), + ['ActivityUserID', 'RegardingUserID'], + ['Join' => ['Name', 'Email', 'Gender', 'Photo']] + ); + $this->calculateData($result->resultArray()); + + $this->EventArguments['Data'] =& $result; + $this->fireEvent('AfterGet'); + + return $result; + } + + /** + * + * + * @param array &$activities + * @since 2.1 + */ + public function joinComments(&$activities) { + // Grab all of the activity IDs. + $activityIDs = []; + foreach ($activities as $activity) { + if ($iD = val('CommentActivityID', $activity['Data'])) { + // This activity shares its comments with another activity. + $activityIDs[] = $iD; + } else { + $activityIDs[] = $activity['ActivityID']; + } + } + $activityIDs = array_unique($activityIDs); + + $comments = $this->getComments($activityIDs); + $comments = Gdn_DataSet::index($comments, ['ActivityID'], ['Unique' => false]); + foreach ($activities as &$activity) { + $iD = val('CommentActivityID', $activity['Data']); + if (!$iD) { + $iD = $activity['ActivityID']; + } + + if (isset($comments[$iD])) { + $activity['Comments'] = $comments[$iD]; + } else { + $activity['Comments'] = []; + } + } + } + + /** + * Modifies standard Gdn_Model->Get to use AcitivityQuery. + * + * Events: BeforeGet, AfterGet. + * + * @param int|bool $notifyUserID Unique ID of user to gather activity for or one of the NOTIFY_* constants in this class. + * @param int $offset Number to skip. + * @param int $limit How many to return. + * @return Gdn_DataSet SQL results. + */ + public function getByUser($notifyUserID = false, $offset = 0, $limit = 30) { + $offset = is_numeric($offset) ? $offset : 0; + if ($offset < 0) { + $offset = 0; + } + + $limit = is_numeric($limit) ? $limit : 0; + if ($limit < 0) { + $limit = 30; + } + + $this->activityQuery(false); + + if ($notifyUserID === false || $notifyUserID === 0) { + $notifyUserID = self::NOTIFY_PUBLIC; + } + $this->SQL->whereIn('NotifyUserID', (array)$notifyUserID); + + $this->fireEvent('BeforeGet'); + $result = $this->SQL + ->orderBy('a.ActivityID', 'desc') + ->limit($limit, $offset) + ->get(); + + Gdn::userModel()->joinUsers($result, ['ActivityUserID', 'RegardingUserID'], ['Join' => ['Name', 'Photo', 'Email', 'Gender']]); + + $this->EventArguments['Data'] =& $result; + $this->fireEvent('AfterGet'); + + return $result; + } + + /** + * + * + * @param array &$data + */ + public static function getUsers(&$data) { + $userIDs = []; + + foreach ($data as &$row) { + if (is_string($row['Data'])) { + $row['Data'] = dbdecode($row['Data']); + } + + $userIDs[$row['ActivityUserID']] = 1; + $userIDs[$row['RegardingUserID']] = 1; + + if (isset($row['Data']['ActivityUserIDs'])) { + foreach ($row['Data']['ActivityUserIDs'] as $userID) { + $userIDs[$userID] = 1; + } + } + + if (isset($row['Data']['RegardingUserIDs'])) { + foreach ($row['Data']['RegardingUserIDs'] as $userID) { + $userIDs[$userID] = 1; + } + } + } + + Gdn::userModel()->getIDs(array_keys($userIDs)); + } + + /** + * + * + * @param $activityType + * @return bool + */ + public static function getActivityType($activityType) { + if (self::$ActivityTypes === null) { + $data = Gdn::sql()->get('ActivityType')->resultArray(); + foreach ($data as $row) { + self::$ActivityTypes[$row['Name']] = $row; + self::$ActivityTypes[$row['ActivityTypeID']] = $row; + } + } + if (isset(self::$ActivityTypes[$activityType])) { + return self::$ActivityTypes[$activityType]; + } + return false; + } + + /** + * Get number of activity related to a user. + * + * Events: BeforeGetCount. + * + * @since 2.0.0 + * @access public + * @param string $userID Unique ID of user. + * @return int Number of activity items found. + */ + public function getCount($userID = '') { + $this->SQL + ->select('a.ActivityID', 'count', 'ActivityCount') + ->from('Activity a') + ->join('ActivityType t', 'a.ActivityTypeID = t.ActivityTypeID'); + + if ($userID != '') { + $this->SQL + ->beginWhereGroup() + ->where('a.ActivityUserID', $userID) + ->orWhere('a.RegardingUserID', $userID) + ->endWhereGroup(); + } + + $session = Gdn::session(); + if (!$session->isValid() || $session->UserID != $userID) { + $this->SQL->where('t.Public', '1'); + } + + $this->fireEvent('BeforeGetCount'); + return $this->SQL + ->get() + ->firstRow() + ->ActivityCount; + } + + /** + * Get activity related to a particular role. + * + * Events: AfterGet. + * + * @param string $roleID Unique ID of role. + * @param int $offset Number to skip. + * @param int $limit Max number to return. + * @return Gdn_DataSet SQL results. + * @since 2.0.18 + */ + public function getForRole($roleID = '', $offset = 0, $limit = 50) { + if (!is_array($roleID)) { + $roleID = [$roleID]; + } + + $offset = is_numeric($offset) ? $offset : 0; + if ($offset < 0) { + $offset = 0; + } + + $limit = is_numeric($limit) ? $limit : 0; + if ($limit < 0) { + $limit = 0; + } + + $this->activityQuery(); + $result = $this->SQL + ->join('UserRole ur', 'a.ActivityUserID = ur.UserID') + ->whereIn('ur.RoleID', $roleID) + ->where('t.Public', '1') + ->orderBy('a.DateInserted', 'desc') + ->limit($limit, $offset) + ->get(); + + $this->EventArguments['Data'] =& $result; + $this->fireEvent('AfterGet'); + + return $result; + } + + /** + * Get number of activity related to a particular role. + * + * @since 2.0.18 + * @access public + * @param int|string $roleID Unique ID of role. + * @return int Number of activity items. + */ + public function getCountForRole($roleID = '') { + if (!is_array($roleID)) { + $roleID = [$roleID]; + } + + return $this->SQL + ->select('a.ActivityID', 'count', 'ActivityCount') + ->from('Activity a') + ->join('ActivityType t', 'a.ActivityTypeID = t.ActivityTypeID') + ->join('UserRole ur', 'a.ActivityUserID = ur.UserID') + ->whereIn('ur.RoleID', $roleID) + ->where('t.Public', '1') + ->get() + ->firstRow() + ->ActivityCount; + } + + /** + * Get a particular activity record. + * + * @param int $activityID Unique ID of activity item. + * @param bool|string $dataSetType The format of the resulting data. + * @param array $options Not used. + * @return array|object A single SQL result. + */ + public function getID($activityID, $dataSetType = false, $options = []) { + $activity = parent::getID($activityID, $dataSetType); + if ($activity) { + $this->calculateRow($activity); + $activities = [$activity]; + self::joinUsers($activities); + $activity = array_pop($activities); + } + + return $activity; + } + + /** + * Get notifications for a user. + * + * Events: BeforeGetNotifications. + * + * @param int $notifyUserID Unique ID of user. + * @param int $offset Number to skip. + * @param int $limit Max number to return. + * @return Gdn_DataSet SQL results. + * @since 2.0.0 + */ + public function getNotifications($notifyUserID, $offset = 0, $limit = 30) { + $this->activityQuery(false); + $this->fireEvent('BeforeGetNotifications'); + $result = $this->SQL + ->where('NotifyUserID', $notifyUserID) + ->limit($limit, $offset) + ->orderBy('a.ActivityID', 'desc') + ->get(); + $result->datasetType(DATASET_TYPE_ARRAY); + + self::getUsers($result->resultArray()); + Gdn::userModel()->joinUsers( + $result->resultArray(), + ['ActivityUserID', 'RegardingUserID'], + ['Join' => ['Name', 'Photo', 'Email', 'Gender']] + ); + $this->calculateData($result->resultArray()); + + return $result; + } + + + /** + * @param $activity + * @return bool + */ + public static function canDelete($activity) { + $session = Gdn::session(); + + $profileUserId = val('ActivityUserID', $activity); + $notifyUserId = val('NotifyUserID', $activity); + + // User can delete any activity + if ($session->checkPermission('Garden.Activity.Delete')) { + return true; + } + + $notifyUserIds = [ActivityModel::NOTIFY_PUBLIC]; + if (Gdn::session()->checkPermission('Garden.Moderation.Manage')) { + $notifyUserIds[] = ActivityModel::NOTIFY_MODS; + } + + // Is this a wall post? + if (!in_array(val('ActivityType', $activity), ['Status', 'WallPost']) || !in_array($notifyUserId, $notifyUserIds)) { + return false; + } + // Is this on the user's wall? + if ($profileUserId && $session->UserID == $profileUserId && $session->checkPermission('Garden.Profiles.Edit')) { + return true; + } + + // The user inserted the activity --- may be added in later +// $insertUserId = val('InsertUserID', $activity); +// if ($insertUserId && $insertUserId == $session->UserID) { +// return true; +// } + + return false; + } + + /** + * Get notifications for a user since designated ActivityID. + * + * Events: BeforeGetNotificationsSince. + * + * @param int $userID Unique ID of user. + * @param int $lastActivityID ID of activity to start at. + * @param array|string $filterToActivityTypeIDs Limits returned activity to particular types. + * @param int $limit Max number to return. + * @return Gdn_DataSet SQL results. + * @since 2.0.18 + */ + public function getNotificationsSince($userID, $lastActivityID, $filterToActivityTypeIDs = '', $limit = 5) { + $this->activityQuery(); + $this->fireEvent('BeforeGetNotificationsSince'); + if (is_array($filterToActivityTypeIDs)) { + $this->SQL->whereIn('a.ActivityTypeID', $filterToActivityTypeIDs); + } else { + $this->SQL->where('t.Notify', '1'); + } + + $result = $this->SQL + ->where('RegardingUserID', $userID) + ->where('a.ActivityID >', $lastActivityID) + ->limit($limit, 0) + ->orderBy('a.ActivityID', 'desc') + ->get(); + + return $result; + } + + /** + * @param int $iD + * @return array|false + */ + public function getComment($iD) { + $activity = $this->SQL->getWhere('ActivityComment', ['ActivityCommentID' => $iD])->resultArray(); + if ($activity) { + Gdn::userModel()->joinUsers($activity, ['InsertUserID'], ['Join' => ['Name', 'Photo', 'Email']]); + return array_shift($activity); + } + return false; + } + + /** + * Get comments related to designated activity items. + * + * Events: BeforeGetComments. + * + * @param array $activityIDs IDs of activity items. + * @return Gdn_DataSet SQL results. + */ + public function getComments($activityIDs) { + $result = $this->SQL + ->select('c.*') + ->from('ActivityComment c') + ->whereIn('c.ActivityID', $activityIDs) + ->orderBy('c.ActivityID, c.DateInserted') + ->get()->resultArray(); + Gdn::userModel()->joinUsers($result, ['InsertUserID'], ['Join' => ['Name', 'Photo', 'Email']]); + return $result; + } + + /** + * Add a new activity item. + * + * Getting reworked for 2.1 so I'm cheating and skipping params for now. -mlr + * + * @param int $activityUserID + * @param string $activityType + * @param string $story + * @param int|null $regardingUserID + * @param int $commentActivityID + * @param string $route + * @param string|bool $sendEmail + * @return int ActivityID of item created. + */ + public function add($activityUserID, $activityType, $story = null, $regardingUserID = null, $commentActivityID = null, $route = null, $sendEmail = '') { + // Get the ActivityTypeID & see if this is a notification. + $activityTypeRow = self::getActivityType($activityType); + $notify = val('Notify', $activityTypeRow, false); + + if ($activityTypeRow === false) { + trigger_error( + errorMessage(sprintf('Activity type could not be found: %s', $activityType), 'ActivityModel', 'Add'), + E_USER_ERROR + ); + } + + $activity = [ + 'ActivityUserID' => $activityUserID, + 'ActivityType' => $activityType, + 'Story' => $story, + 'RegardingUserID' => $regardingUserID, + 'Route' => $route + ]; + + + // Massage $SendEmail to allow for only sending an email. + if ($sendEmail === 'Only') { + $sendEmail = ''; + } elseif ($sendEmail === 'QueueOnly') { + $sendEmail = ''; + $notify = true; + } + + // If $SendEmail was FALSE or TRUE, let it override the $Notify setting. + if ($sendEmail === false || $sendEmail === true) { + $notify = $sendEmail; + } + + $preference = false; + if (($activityTypeRow['Notify'] || !$activityTypeRow['Public']) && !empty($regardingUserID)) { + $activity['NotifyUserID'] = $activity['RegardingUserID']; + $preference = $activityType; + } else { + $activity['NotifyUserID'] = self::NOTIFY_PUBLIC; + } + + // Otherwise let the decision to email lie with the $Notify setting. + if ($sendEmail === 'Force' || $notify) { + $activity['Emailed'] = self::SENT_PENDING; + } elseif ($notify) { + $activity['Emailed'] = self::SENT_PENDING; + } elseif ($sendEmail === false) { + $activity['Emailed'] = self::SENT_ARCHIVE; + } + + $activity = $this->save($activity, $preference); + + return val('ActivityID', $activity); + } + + /** + * Join the users to the activities. + * + * @param array|Gdn_DataSet &$activities The activities to join. + */ + public static function joinUsers(&$activities) { + Gdn::userModel()->joinUsers( + $activities, + ['ActivityUserID', 'RegardingUserID'], + ['Join' => ['Name', 'Email', 'Gender', 'Photo']] + ); + } + + /** + * Get default notification preference for an activity type. + * + * @since 2.0.0 + * @access public + * @param string $activityType + * @param array $preferences + * @param string $type One of the following: + * - Popup: Popup a notification. + * - Email: Email the notification. + * - NULL: True if either notification is true. + * - both: Return an array of (Popup, Email). + * @return bool|bool[] + */ + public static function notificationPreference($activityType, $preferences, $type = null) { + if (is_numeric($preferences)) { + $user = Gdn::userModel()->getID($preferences); + if (!$user) { + return $type == 'both' ? [false, false] : false; + } + $preferences = val('Preferences', $user); + } + + if ($type === null) { + $result = self::notificationPreference($activityType, $preferences, 'Email') + || self::notificationPreference($activityType, $preferences, 'Popup'); + + return $result; + } elseif ($type === 'both') { + $result = [ + self::notificationPreference($activityType, $preferences, 'Popup'), + self::notificationPreference($activityType, $preferences, 'Email') + ]; + return $result; + } + + $configPreference = c("Preferences.$type.$activityType", '0'); + if ((int)$configPreference === 2) { + $preference = true; // This preference is forced on. + } elseif ($configPreference !== false) { + $preference = val($type.'.'.$activityType, $preferences, $configPreference); + } else { + $preference = false; + } + + return $preference; + } + + /** + * Send notification. + * + * @since 2.0.17 + * @access public + * @param int $ActivityID + * @param array|string $Story + * @param bool $Force + */ + public function sendNotification($ActivityID, $Story = '', $Force = false) { + $Activity = $this->getID($ActivityID); + if (!$Activity) { + return; + } + + $Activity = (object)$Activity; + + $Story = Gdn_Format::text($Story == '' ? $Activity->Story : $Story, false); + // If this is a comment on another activity, fudge the activity a bit so that everything appears properly. + if (is_null($Activity->RegardingUserID) && $Activity->CommentActivityID > 0) { + $CommentActivity = $this->getID($Activity->CommentActivityID); + $Activity->RegardingUserID = $CommentActivity->RegardingUserID; + $Activity->Route = '/activity/item/'.$Activity->CommentActivityID; + } + + $User = Gdn::userModel()->getID($Activity->RegardingUserID, DATASET_TYPE_OBJECT); + + if ($User) { + if ($Force) { + $Preference = $Force; + } else { + $Preferences = $User->Preferences; + $Preference = val('Email.'.$Activity->ActivityType, $Preferences, Gdn::config('Preferences.Email.'.$Activity->ActivityType)); + } + if ($Preference) { + $ActivityHeadline = Gdn_Format::text(Gdn_Format::activityHeadline($Activity, $Activity->ActivityUserID, $Activity->RegardingUserID), false); + $Email = new Gdn_Email(); + $Email->subject($ActivityHeadline); + $Email->to($User); + + $url = externalUrl(val('Route', $Activity) == '' ? '/' : val('Route', $Activity)); + $emailTemplate = $Email->getEmailTemplate() + ->setButton($url, val('ActionText', $Activity, t('Check it out'))) + ->setTitle($ActivityHeadline); + + if ($message = $this->getEmailMessage($Activity)) { + $emailTemplate->setMessage($message, true); + } + + $Email->setEmailTemplate($emailTemplate); + + $Notification = ['ActivityID' => $ActivityID, 'User' => $User, 'Email' => $Email, 'Route' => $Activity->Route, 'Story' => $Story, 'Headline' => $ActivityHeadline, 'Activity' => $Activity]; + $this->EventArguments = $Notification; + $this->fireEvent('BeforeSendNotification'); + try { + // Only send if the user is not banned + if (!val('Banned', $User)) { + $Email->send(); + $Emailed = self::SENT_OK; + } else { + $Emailed = self::SENT_SKIPPED; + } + } catch (phpmailerException $pex) { + if ($pex->getCode() == PHPMailer::STOP_CRITICAL && !$Email->PhpMailer->isServerError($pex)) { + $Emailed = self::SENT_FAIL; + } else { + $Emailed = self::SENT_ERROR; + } + } catch (Exception $ex) { + switch ($ex->getCode()) { + case Gdn_Email::ERR_SKIPPED: + $Emailed = self::SENT_SKIPPED; + break; + default: + $Emailed = self::SENT_FAIL; // similar to http 5xx + } + } + try { + $this->SQL->put('Activity', ['Emailed' => $Emailed], ['ActivityID' => $ActivityID]); + } catch (Exception $Ex) { + // We don't want a noisy error in a behind-the-scenes notification. + } + } + } + } + + + /** + * Takes an array representing an activity and builds the email message based on the activity's story and + * the contents of the global config Garden.Email.Prefix. + * + * @param array|object $activity The activity to build the email for. + * @return string The email message. + */ + private function getEmailMessage($activity) { + $message = ''; + + if ($prefix = c('Garden.Email.Prefix', '')) { + $message = $prefix; + } + + $isArray = is_array($activity); + + $story = $isArray ? $activity['Story'] ?? null : $activity->Story ?? null; + $format = $isArray ? $activity['Format'] ?? null : $activity->Format ?? null; + + if ($story && $format) { + $message .= Gdn_Format::to($story, $format); + } + + return $message; + } + + /** + * + * + * @param $activity + * @param array $options Options to modify the behavior of the emailing. + * + * - **NoDelete**: Don't delete an email-only activity once the email is sent. + * - **EmailSubject**: A custom subject for the email. + * @return bool + * @throws Exception + */ + public function email(&$activity, $options = []) { + // The $options parameter used to be $noDelete bool, this is the backwards compat. + if (is_bool($options)) { + $options = ['NoDelete' => $options]; + } + $options += [ + 'NoDelete' => false, + 'EmailSubject' => '', + ]; + + if (is_numeric($activity)) { + $activityID = $activity; + $activity = $this->getID($activityID); + } else { + $activityID = val('ActivityID', $activity); + } + + if (!$activity) { + return false; + } + + $activity = (array)$activity; + + $user = Gdn::userModel()->getID($activity['NotifyUserID'], DATASET_TYPE_ARRAY); + if (!$user) { + return false; + } + + // Format the activity headline based on the user being emailed. + if (val('HeadlineFormat', $activity)) { + $sessionUserID = Gdn::session()->UserID; + Gdn::session()->UserID = $user['UserID']; + $activity['Headline'] = formatString($activity['HeadlineFormat'], $activity); + Gdn::session()->UserID = $sessionUserID; + } else { + if (!isset($activity['ActivityGender'])) { + $aT = self::getActivityType($activity['ActivityType']); + + $data = [$activity]; + self::joinUsers($data); + $activity = $data[0]; + $activity['RouteCode'] = val('RouteCode', $aT); + $activity['FullHeadline'] = val('FullHeadline', $aT); + $activity['ProfileHeadline'] = val('ProfileHeadline', $aT); + } + + $activity['Headline'] = Gdn_Format::activityHeadline($activity, '', $user['UserID']); + } + + $subject = $options['EmailSubject'] ?: Gdn_Format::plainText($activity['Headline']); + + // Build the email to send. + $email = new Gdn_Email(); + // FIX: remove app title + $email->subject($subject); + $email->to($user); + + $url = externalUrl(val('Route', $activity) == '' ? '/' : val('Route', $activity)); + + $emailTemplate = $email->getEmailTemplate() + ->setButton($url, val('ActionText', $activity, t('Check it out'))) + ->setTitle($subject); + + if ($message = $this->getEmailMessage($activity)) { + $emailTemplate->setMessage($message, true); + } + + $email->setEmailTemplate($emailTemplate); + + // Fire an event for the notification. + $notification = ['ActivityID' => $activityID, 'User' => $user, 'Email' => $email, 'Route' => $activity['Route'], 'Story' => $activity['Story'], 'Headline' => $activity['Headline'], 'Activity' => $activity]; + $this->EventArguments = $notification; + $this->fireEvent('BeforeSendNotification'); + + // Send the email. + try { + // Only send if the user is not banned + if (!val('Banned', $user)) { + $email->send(); + $emailed = self::SENT_OK; + } else { + $emailed = self::SENT_SKIPPED; + } + + // Delete the activity now that it has been emailed. + if (!$options['NoDelete'] && !$activity['Notified']) { + if (val('ActivityID', $activity)) { + $this->delete($activity['ActivityID']); + } else { + $activity['_Delete'] = true; + } + } + } catch (phpmailerException $pex) { + if ($pex->getCode() == PHPMailer::STOP_CRITICAL && !$email->PhpMailer->isServerError($pex)) { + $emailed = self::SENT_FAIL; + } else { + $emailed = self::SENT_ERROR; + } + } catch (Exception $ex) { + switch ($ex->getCode()) { + case Gdn_Email::ERR_SKIPPED: + $emailed = self::SENT_SKIPPED; + break; + default: + $emailed = self::SENT_FAIL; // similar to http 5xx + } + } + $activity['Emailed'] = $emailed; + if ($activityID) { + // Save the emailed flag back to the activity. + $this->SQL->put('Activity', ['Emailed' => $emailed], ['ActivityID' => $activityID]); + } + return true; + } + + /** + * @var array The Notification Queue is used to stack up notifications to users. Ensures + * that they only receive one notification about a single topic. For example: + * if someone comments on a discussion that they started and they have + * bookmarked, it will only notify them about one or the other, not both. + * + * This code makes the assumption that the queue is used for one user action + * at a time. For example: a comment being added to a discussion. The queue + * should be cleared before it is used, and sending the queue will clear it + * again. + */ + private $_NotificationQueue = []; + + /** + * Clear notification queue. + * + * @since 2.0.17 + * @access public + */ + public function clearNotificationQueue() { + unset($this->_NotificationQueue); + $this->_NotificationQueue = []; + } + + /** + * Save a comment on an activity. + * + * @param array $comment + * @return int|bool|string + * @since 2.1 + */ + public function comment($comment) { + $comment['InsertUserID'] = Gdn::session()->UserID; + $comment['DateInserted'] = Gdn_Format::toDateTime(); + $comment['InsertIPAddress'] = ipEncode(Gdn::request()->ipAddress()); + + $this->Validation->applyRule('ActivityID', 'Required'); + $this->Validation->applyRule('Body', 'Required'); + $this->Validation->applyRule('DateInserted', 'Required'); + $this->Validation->applyRule('InsertUserID', 'Required'); + + $this->EventArguments['Comment'] = &$comment; + $this->fireEvent('BeforeSaveComment'); + + if ($this->validate($comment)) { + $activity = $this->getID($comment['ActivityID'], DATASET_TYPE_ARRAY); + + $_ActivityID = $comment['ActivityID']; + // Check to see if this is a shared activity/notification. + if ($commentActivityID = val('CommentActivityID', $activity['Data'])) { + Gdn::controller()->json('CommentActivityID', $commentActivityID); + $comment['ActivityID'] = $commentActivityID; + } + + $storageObject = FloodControlHelper::configure($this, 'Vanilla', 'ActivityComment'); + if ($this->checkUserSpamming(Gdn::session()->User->UserID, $storageObject)) { + return false; + } + + // Check for spam. + $spam = SpamModel::isSpam('ActivityComment', $comment); + if ($spam) { + return SPAM; + } + + // Check for approval + $approvalRequired = checkRestriction('Vanilla.Approval.Require'); + if ($approvalRequired && !val('Verified', Gdn::session()->User)) { + LogModel::insert('Pending', 'ActivityComment', $comment); + return UNAPPROVED; + } + + $iD = $this->SQL->insert('ActivityComment', $comment); + + if ($iD) { + // Check to see if this comment bumps the activity. + if ($activity && val('Bump', $activity['Data'])) { + $this->SQL->put('Activity', ['DateUpdated' => $comment['DateInserted']], ['ActivityID' => $activity['ActivityID']]); + if ($_ActivityID != $comment['ActivityID']) { + $this->SQL->put('Activity', ['DateUpdated' => $comment['DateInserted']], ['ActivityID' => $_ActivityID]); + } + } + + // Send a notification to the original person. + if (val('ActivityType', $activity) === 'WallPost') { + $this->notifyWallComment($comment, $activity); + } + } + + return $iD; + } + return false; + } + + /** + * Send all notifications in the queue. + * + * @since 2.0.17 + * @access public + */ + public function sendNotificationQueue() { + foreach ($this->_NotificationQueue as $userID => $notifications) { + if (is_array($notifications)) { + // Only send out one notification per user. + $notification = $notifications[0]; + + /* @var Gdn_Email $Email */ + $email = $notification['Email']; + + if (is_object($email) && method_exists($email, 'send')) { + $this->EventArguments = $notification; + $this->fireEvent('BeforeSendNotification'); + + try { + // Only send if the user is not banned + $user = Gdn::userModel()->getID($userID); + if (!val('Banned', $user)) { + $email->send(); + $emailed = self::SENT_OK; + } else { + $emailed = self::SENT_SKIPPED; + } + } catch (phpmailerException $pex) { + if ($pex->getCode() == PHPMailer::STOP_CRITICAL && !$email->PhpMailer->isServerError($pex)) { + $emailed = self::SENT_FAIL; + } else { + $emailed = self::SENT_ERROR; + } + } catch (Exception $ex) { + switch ($ex->getCode()) { + case Gdn_Email::ERR_SKIPPED: + $emailed = self::SENT_SKIPPED; + break; + default: + $emailed = self::SENT_FAIL; + } + } + + try { + $this->SQL->put('Activity', ['Emailed' => $emailed], ['ActivityID' => $notification['ActivityID']]); + } catch (Exception $ex) { + // Ignore an exception in a behind-the-scenes notification. + } + } + } + } + + // Clear out the queue + unset($this->_NotificationQueue); + $this->_NotificationQueue = []; + } + + /** + * Get total unread notifications for a user. + * + * @param integer $userID + */ + public function getUserTotalUnread($userID) { + $notifications = $this->SQL + ->select("ActivityID", "count", "total") + ->from($this->Name) + ->where("NotifyUserID", $userID) + ->where("Notified", self::SENT_PENDING) + ->get() + ->resultArray(); + if (!is_array($notifications) || !isset($notifications[0])) { + return 0; + } + return $notifications[0]["total"] ?? 0; + } + + /** + * + * + * @param $activityIDs + * @throws Exception + */ + public function setNotified($activityIDs) { + if (!is_array($activityIDs) || count($activityIDs) == 0) { + return; + } + + $this->SQL->update('Activity') + ->set('Notified', self::SENT_OK) + ->whereIn('ActivityID', $activityIDs) + ->put(); + } + + /** + * + * + * @param $activity + * @throws Exception + */ + public function share(&$activity) { + // Massage the event for the user. + $this->EventArguments['RecordType'] = 'Activity'; + $this->EventArguments['Activity'] =& $activity; + + $this->fireEvent('Share'); + } + + /** + * Queue a notification for sending. + * + * @since 2.0.17 + * @access public + * @param int $activityID + * @param string $story + * @param string $position + * @param bool $force + */ + public function queueNotification($activityID, $story = '', $position = 'last', $force = false) { + $activity = $this->getID($activityID); + if (!is_object($activity)) { + return; + } + + $story = Gdn_Format::text($story == '' ? $activity->Story : $story, false); + // If this is a comment on another activity, fudge the activity a bit so that everything appears properly. + if (is_null($activity->RegardingUserID) && $activity->CommentActivityID > 0) { + $commentActivity = $this->getID($activity->CommentActivityID); + $activity->RegardingUserID = $commentActivity->RegardingUserID; + $activity->Route = '/activity/item/'.$activity->CommentActivityID; + } + $user = Gdn::userModel()->getID($activity->RegardingUserID, DATASET_TYPE_OBJECT); + + if ($user) { + if ($force) { + $preference = $force; + } else { + $configPreference = c('Preferences.Email.'.$activity->ActivityType, '0'); + if ($configPreference !== false) { + $preference = val('Email.'.$activity->ActivityType, $user->Preferences, $configPreference); + } else { + $preference = false; + } + } + + if ($preference) { + $activityHeadline = Gdn_Format::text(Gdn_Format::activityHeadline($activity, $activity->ActivityUserID, $activity->RegardingUserID), false); + $email = new Gdn_Email(); + $email->subject($activityHeadline); + $email->to($user); + $url = externalUrl(val('Route', $activity) == '' ? '/' : val('Route', $activity)); + + $emailTemplate = $email->getEmailTemplate() + ->setButton($url, val('ActionText', $activity, t('Check it out'))) + ->setTitle(Gdn_Format::plainText(val('Headline', $activity))); + + if ($message = $this->getEmailMessage($activity)) { + $emailTemplate->setMessage($message, true); + } + + $email->setEmailTemplate($emailTemplate); + + if (!array_key_exists($user->UserID, $this->_NotificationQueue)) { + $this->_NotificationQueue[$user->UserID] = []; + } + + $notification = ['ActivityID' => $activityID, 'User' => $user, 'Email' => $email, 'Route' => $activity->Route, 'Story' => $story, 'Headline' => $activityHeadline, 'Activity' => $activity]; + if ($position == 'first') { + $this->_NotificationQueue[$user->UserID] = array_merge([$notification], $this->_NotificationQueue[$user->UserID]); + } else { + $this->_NotificationQueue[$user->UserID][] = $notification; + } + } + } + } + + /** + * Queue an activity for saving later. + * + * @param array $data The data in the activity. + * @param string|bool $preference The name of the preference governing the activity. + * @param array $options Additional options for saving. + * @throws Exception + */ + public function queue($data, $preference = false, $options = []) { + $this->_touch($data); + if (!isset($data['NotifyUserID']) || !isset($data['ActivityType'])) { + throw new Exception('Data missing NotifyUserID and/or ActivityType', 400); + } + + if ($data['ActivityUserID'] == $data['NotifyUserID'] && !val('Force', $options)) { + return; // don't notify users of something they did. + } + $notified = $data['Notified']; + $emailed = $data['Emailed']; + + if (isset(self::$Queue[$data['NotifyUserID']][$data['ActivityType']])) { + list($currentData, $currentOptions) = self::$Queue[$data['NotifyUserID']][$data['ActivityType']]; + + $notified = $notified ? $notified : $currentData['Notified']; + $emailed = $emailed ? $emailed : $currentData['Emailed']; + + $reason = null; + if (isset($currentData['Data']['Reason']) && isset($data['Data']['Reason'])) { + $reason = array_merge((array)$currentData['Data']['Reason'], (array)$data['Data']['Reason']); + $reason = array_unique($reason); + } + + $data = array_merge($currentData, $data); + $options = array_merge($currentOptions, $options); + if ($reason) { + $data['Data']['Reason'] = $reason; + } + } + + $this->EventArguments['Preference'] = $preference; + $this->EventArguments['Options'] = $options; + $this->EventArguments['Data'] = &$data; + $this->fireEvent('BeforeCheckPreference'); + if (!empty($preference)) { + list($popup, $email) = self::notificationPreference($preference, $data['NotifyUserID'], 'both'); + if (!$popup && !$email) { + return; // don't queue if user doesn't want to be notified at all. + } + if ($popup) { + $notified = self::SENT_PENDING; + } + if ($email) { + $emailed = self::SENT_PENDING; + } + } + $data['Notified'] = $notified; + $data['Emailed'] = $emailed; + + self::$Queue[$data['NotifyUserID']][$data['ActivityType']] = [$data, $options]; + } + + /** + * + * + * @param array $data + * @param bool $preference + * @param array $options + * @return array|bool|string|null + * @throws Exception + */ + public function save($data, $preference = false, $options = []) { + trace('ActivityModel->save()'); + $activity = $data; + $this->_touch($activity); + + if ($activity['ActivityUserID'] == $activity['NotifyUserID'] && !val('Force', $options)) { + trace('Skipping activity because it would notify the user of something they did.'); + + return null; // don't notify users of something they did. + } + + // Check the user's preference. + if ($preference) { + list($popup, $email) = self::notificationPreference($preference, $activity['NotifyUserID'], 'both'); + + if ($popup && !$activity['Notified']) { + $activity['Notified'] = self::SENT_PENDING; + } + if ($email && !$activity['Emailed']) { + $activity['Emailed'] = self::SENT_PENDING; + } + + if (!$activity['Notified'] && !$activity['Emailed'] && !val('Force', $options)) { + trace("Skipping activity because the user has no preference set."); + return null; + } + } + + $activityType = self::getActivityType($activity['ActivityType']); + $activityTypeID = val('ActivityTypeID', $activityType); + if (!$activityTypeID) { + trace("There is no $activityType activity type.", TRACE_WARNING); + $activityType = self::getActivityType('Default'); + $activityTypeID = val('ActivityTypeID', $activityType); + } + + $activity['ActivityTypeID'] = $activityTypeID; + + $notificationInc = 0; + if ($activity['NotifyUserID'] > 0 && $activity['Notified']) { + $notificationInc = 1; + } + + // Check to see if we are sharing this activity with another one. + if ($commentActivityID = val('CommentActivityID', $activity['Data'])) { + $commentActivity = $this->getID($commentActivityID); + $activity['Data']['CommentNotifyUserID'] = $commentActivity['NotifyUserID']; + } + + // Make sure this activity isn't a duplicate. + if (val('CheckRecord', $options)) { + // Check to see if this record already notified so we don't notify multiple times. + $where = arrayTranslate($activity, ['NotifyUserID', 'RecordType', 'RecordID']); + $where['DateUpdated >'] = Gdn_Format::toDateTime(strtotime('-2 days')); // index hint + + $checkActivity = $this->SQL->getWhere( + 'Activity', + $where + )->firstRow(); + + if ($checkActivity) { + return false; + } + } + + // Check to share the activity. + if (val('Share', $options)) { + $this->share($activity); + } + + // Group he activity. + if ($groupBy = val('GroupBy', $options)) { + $groupBy = (array)$groupBy; + $where = []; + foreach ($groupBy as $columnName) { + $where[$columnName] = $activity[$columnName]; + } + $where['NotifyUserID'] = $activity['NotifyUserID']; + // Make sure to only group activities by day. + $where['DateInserted >'] = Gdn_Format::toDateTime(strtotime('-1 day')); + + // See if there is another activity to group these into. + $groupActivity = $this->SQL->getWhere( + 'Activity', + $where + )->firstRow(DATASET_TYPE_ARRAY); + + if ($groupActivity) { + $groupActivity['Data'] = dbdecode($groupActivity['Data']); + $activity = $this->mergeActivities($groupActivity, $activity); + $notificationInc = 0; + } + } + + $delete = false; + if ($activity['Emailed'] == self::SENT_PENDING) { + $this->email($activity, $options); + $delete = val('_Delete', $activity); + } + + $activityData = $activity['Data']; + if (isset($activity['Data']) && is_array($activity['Data'])) { + $activity['Data'] = dbencode($activity['Data']); + } + + $this->defineSchema(); + $activity = $this->filterSchema($activity); + + $activityID = val('ActivityID', $activity); + if (!$activityID) { + if (!$delete) { + if (!val('DisableFloodControl', $options)) { + $storageObject = FloodControlHelper::configure($this, 'Vanilla', 'Activity'); + if ($this->checkUserSpamming(Gdn::session()->UserID, $storageObject)) { + return false; + } + } + + $this->addInsertFields($activity); + touchValue('DateUpdated', $activity, $activity['DateInserted']); + + $this->EventArguments['Activity'] =& $activity; + $this->EventArguments['ActivityID'] = null; + + $handled = false; + $this->EventArguments['Handled'] =& $handled; + + $this->fireEvent('BeforeSave'); + + if (count($this->validationResults()) > 0) { + return false; + } + + if ($handled) { + // A plugin handled this activity so don't save it. + return $activity; + } + + if (val('CheckSpam', $options)) { + // Check for spam + $spam = SpamModel::isSpam('Activity', $activity); + if ($spam) { + return SPAM; + } + + // Check for approval + $approvalRequired = checkRestriction('Vanilla.Approval.Require'); + if ($approvalRequired && !val('Verified', Gdn::session()->User)) { + LogModel::insert('Pending', 'Activity', $activity); + return UNAPPROVED; + } + } + + $activityID = $this->SQL->insert('Activity', $activity); + $activity['ActivityID'] = $activityID; + + $this->prune(); + } + } else { + $activity['DateUpdated'] = Gdn_Format::toDateTime(); + unset($activity['ActivityID']); + + $this->EventArguments['Activity'] =& $activity; + $this->EventArguments['ActivityID'] = $activityID; + $this->fireEvent('BeforeSave'); + + if (count($this->validationResults()) > 0) { + return false; + } + + $this->SQL->put('Activity', $activity, ['ActivityID' => $activityID]); + $activity['ActivityID'] = $activityID; + } + $activity['Data'] = $activityData; + + if (isset($commentActivity)) { + $commentActivity['Data']['SharedActivityID'] = $activity['ActivityID']; + $commentActivity['Data']['SharedNotifyUserID'] = $activity['NotifyUserID']; + $this->setField($commentActivity['ActivityID'], 'Data', $commentActivity['Data']); + } + + if ($notificationInc > 0) { + $countNotifications = Gdn::userModel()->getID($activity['NotifyUserID'])->CountNotifications + $notificationInc; + Gdn::userModel()->setField($activity['NotifyUserID'], 'CountNotifications', $countNotifications); + } + + // If this is a wall post then we need to notify on that. + if (val('Name', $activityType) == 'WallPost' && $activity['NotifyUserID'] == self::NOTIFY_PUBLIC) { + $this->notifyWallPost($activity); + } + + return $activity; + } + + /** + * Update a single activity's notification fields to reflect a read status. + * + * @param int $activityID + */ + public function markSingleRead(int $activityID) { + $this->SQL->put( + "Activity", + ["Notified" => self::SENT_OK, "Emailed" => self::SENT_OK], + ["ActivityID" => $activityID] + ); + } + + /** + * + * + * @param $userID + */ + public function markRead($userID) { + // Mark all of a user's unread activities read. + $this->SQL->put( + 'Activity', + ['Notified' => self::SENT_OK], + ['NotifyUserID' => $userID, 'Notified' => self::SENT_PENDING] + ); + + $user = Gdn::userModel()->getID($userID); + if (val('CountNotifications', $user) != 0) { + Gdn::userModel()->setField($userID, 'CountNotifications', 0); + } + } + + /** + * + * + * @param $oldActivity + * @param $newActivity + * @param array $options + * @return array + */ + public function mergeActivities($oldActivity, $newActivity, $options = []) { + // Group the two activities together. + $activityUserIDs = val('ActivityUserIDs', $oldActivity['Data'], []); + $activityUserCount = val('ActivityUserID_Count', $oldActivity['Data'], 0); + array_unshift($activityUserIDs, $oldActivity['ActivityUserID']); + if (($i = array_search($newActivity['ActivityUserID'], $activityUserIDs)) !== false) { + unset($activityUserIDs[$i]); + $activityUserIDs = array_values($activityUserIDs); + } + $activityUserIDs = array_unique($activityUserIDs); + if (count($activityUserIDs) > self::$MaxMergeCount) { + array_pop($activityUserIDs); + $activityUserCount++; + } + + $regardingUserCount = 0; + if (val('RegardingUserID', $newActivity)) { + $regardingUserIDs = val('RegardingUserIDs', $oldActivity['Data'], []); + $regardingUserCount = val('RegardingUserID_Count', $oldActivity['Data'], 0); + array_unshift($regardingUserIDs, $oldActivity['RegardingUserID']); + if (($i = array_search($newActivity['RegardingUserID'], $regardingUserIDs)) !== false) { + unset($regardingUserIDs[$i]); + $regardingUserIDs = array_values($regardingUserIDs); + } + if (count($regardingUserIDs) > self::$MaxMergeCount) { + array_pop($regardingUserIDs); + $regardingUserCount++; + } + } + + $recordIDs = []; + if ($oldActivity['RecordID']) { + $recordIDs[] = $oldActivity['RecordID']; + } + $recordIDs = array_unique($recordIDs); + + $newActivity = array_merge($oldActivity, $newActivity); + + if (count($activityUserIDs) > 0) { + $newActivity['Data']['ActivityUserIDs'] = $activityUserIDs; + } + if ($activityUserCount) { + $newActivity['Data']['ActivityUserID_Count'] = $activityUserCount; + } + if (count($recordIDs) > 0) { + $newActivity['Data']['RecordIDs'] = $recordIDs; + } + if (isset($regardingUserIDs) && count($regardingUserIDs) > 0) { + $newActivity['Data']['RegardingUserIDs'] = $regardingUserIDs; + + if ($regardingUserCount) { + $newActivity['Data']['RegardingUserID_Count'] = $regardingUserCount; + } + } + + return $newActivity; + } + + /** + * Notify the user of wall comments. + * + * @param array $comment + * @param $wallPost + */ + protected function notifyWallComment($comment, $wallPost) { + $notifyUser = Gdn::userModel()->getID($wallPost['ActivityUserID']); + + $activity = [ + 'ActivityType' => 'WallComment', + 'ActivityUserID' => $comment['InsertUserID'], + 'Format' => $comment['Format'], + 'NotifyUserID' => $wallPost['ActivityUserID'], + 'RecordType' => 'ActivityComment', + 'RecordID' => $comment['ActivityCommentID'], + 'RegardingUserID' => $wallPost['ActivityUserID'], + 'Route' => userUrl($notifyUser, ''), + 'Story' => $comment['Body'], + 'HeadlineFormat' => t('HeadlineFormat.NotifyWallComment', '{ActivityUserID,User} commented on your wall.') + ]; + + $this->save($activity, 'WallComment'); + } + + /** + * + * + * @param $wallPost + */ + protected function notifyWallPost($wallPost) { + $notifyUser = Gdn::userModel()->getID($wallPost['ActivityUserID']); + + $activity = [ + 'ActivityType' => 'WallPost', + 'ActivityUserID' => $wallPost['RegardingUserID'], + 'Format' => $wallPost['Format'], + 'NotifyUserID' => $wallPost['ActivityUserID'], + 'RecordType' => 'Activity', + 'RecordID' => $wallPost['ActivityID'], + 'RegardingUserID' => $wallPost['ActivityUserID'], + 'Route' => userUrl($notifyUser, ''), + 'Story' => $wallPost['Story'], + 'HeadlineFormat' => t('HeadlineFormat.NotifyWallPost', '{ActivityUserID,User} posted on your wall.') + ]; + + $this->save($activity, 'WallComment'); + } + + /** + * + * + * @return array + */ + public function saveQueue() { + $result = []; + foreach (self::$Queue as $userID => $activities) { + foreach ($activities as $activityType => $row) { + $result[] = $this->save($row[0], false, ['DisableFloodControl' => true] + $row[1]); + } + } + self::$Queue = []; + return $result; + } + + /** + * + * + * @param $data + */ + protected function _touch(&$data) { + touchValue('ActivityType', $data, 'Default'); + touchValue('ActivityUserID', $data, Gdn::session()->UserID); + touchValue('NotifyUserID', $data, self::NOTIFY_PUBLIC); + touchValue('Headline', $data, null); + touchValue('Story', $data, null); + touchValue('Notified', $data, 0); + touchValue('Emailed', $data, 0); + touchValue('Photo', $data, null); + touchValue('Route', $data, null); + if (!isset($data['Data']) || !is_array($data['Data'])) { + $data['Data'] = []; + } + } + + /** + * Get the delete after time. + * + * @return string Returns a string compatible with {@link strtotime()}. + */ + public function getPruneAfter() { + return $this->pruneAfter; + } + + /** + * Get the exact timestamp to prune. + * + * @return \DateTime|null Returns the date that we should prune after. + */ + private function getPruneDate() { + if (!$this->pruneAfter) { + return null; + } else { + $tz = new \DateTimeZone('UTC'); + $now = new DateTime('now', $tz); + $test = new DateTime($this->pruneAfter, $tz); + + $interval = $test->diff($now); + + if ($interval->invert === 1) { + return $now->add($interval); + } else { + return $test; + } + } + } + + /** + * Set the prune after date. + * + * @param string $pruneAfter A string compatible with {@link strtotime()}. Be sure to specify a negative string. + * @return ActivityModel Returns `$this` for fluent calls. + */ + public function setPruneAfter($pruneAfter) { + if ($pruneAfter) { + // Make sure the string is negative. + $now = time(); + $testTime = strtotime($pruneAfter, $now); + if ($testTime === false) { + throw new InvalidArgumentException('Invalid timespan value for "prune after".', 400); + } + } + + $this->pruneAfter = $pruneAfter; + return $this; + } + + /** + * Prune old activities. + */ + private function prune() { + $date = $this->getPruneDate(); + + $this->SQL->delete( + 'Activity', + ['DateUpdated <' => Gdn_Format::toDateTime($date->getTimestamp())], + 10 + ); + } +} diff --git a/vanilla/applications/dashboard/views/modules/dropdown.php b/vanilla/applications/dashboard/views/modules/dropdown.php new file mode 100644 index 0000000..7b90816 --- /dev/null +++ b/vanilla/applications/dashboard/views/modules/dropdown.php @@ -0,0 +1,69 @@ +getTrigger(); +?> + + + + + + data('DashboardCount', '')) ? wrap($dropdown->data('DashboardCount', ''), 'span', ['class' => 'Alert']) : ''; + echo anchor($icon.$text.$alert, $url, $cssClass, $attributes); + endif; ?> + + diff --git a/vanilla/applications/vanilla/controllers/class.categoriescontroller.php b/vanilla/applications/vanilla/controllers/class.categoriescontroller.php index 19b3be1..17ace55 100644 --- a/vanilla/applications/vanilla/controllers/class.categoriescontroller.php +++ b/vanilla/applications/vanilla/controllers/class.categoriescontroller.php @@ -34,7 +34,6 @@ class CategoriesController extends VanillaController { const SORT_LAST_POST = 'new'; const SORT_OLDEST_POST = 'old'; - const ROOT_CATEGORY = ['Name' => 'Roundtables', 'Url'=>'/']; /** * @var \Closure $categoriesCompatibilityCallback A backwards-compatible callback to get `$this->data('Categories')`. */ @@ -140,8 +139,10 @@ private function getCategoryTree($category = null, $displayAs = null, $recent = $perPage = c('Vanilla.Categories.PerPage', 30); $page = Gdn::request()->get('Page', Gdn::request()->get('page', null)); list($offset, $limit) = offsetLimit($page, $perPage); - $categoryTree = $this->CategoryModel->getTreeAsFlat($categoryIdentifier, $offset, $limit); + $categoryTree = $this->CategoryModel->getTreeAsFlat($categoryIdentifier, $offset, $limit,null, 'c.DateInserted', 'desc'); + $countOfCategoryTree = $this->CategoryModel->countOfCategories($categoryIdentifier, null); $this->setData('_Limit', $perPage); + $this->setData('_RecordCount', $countOfCategoryTree); $this->setData('_CurrentRecords', count($categoryTree)); break; case 'Categories': @@ -333,11 +334,7 @@ public function index($categoryIdentifier = '', $page = '0') { } // Load the breadcrumbs. - - $ancestors = CategoryModel::getAncestors(val('CategoryID', $category)); - array_unshift ( $ancestors , self::ROOT_CATEGORY); - $this->setData('Breadcrumbs', $ancestors); - + $this->setData('Breadcrumbs', $this->buildBreadcrumbs(val('CategoryID', $category))); $this->setData('Category', $category, true); // Set CategoryID @@ -408,8 +405,10 @@ public function index($categoryIdentifier = '', $page = '0') { $this->Head->addRss(categoryUrl($category) . '/feed.rss', $this->Head->title()); } - // Add modules - $this->addModule('NewDiscussionModule'); + if($category->DisplayAs == 'Discussions') { + // Add modules + $this->addModule('NewDiscussionModule'); + } $this->addModule('DiscussionFilterModule'); // $this->addModule('CategoriesModule'); $this->addModule('BookmarkedModule'); @@ -549,10 +548,7 @@ public function all($Category = '', $displayAs = '') { $this->description(c('Garden.Description', null)); } - $ancestors = CategoryModel::getAncestors(val('CategoryID', $this->data('Category'))); - array_unshift ( $ancestors , self::ROOT_CATEGORY); - $this->setData('Breadcrumbs', $ancestors); - + $this->setData('Breadcrumbs', $this->buildBreadcrumbs(val('CategoryID', $this->data('Category')))); // Set the category follow toggle before we load category data so that it affects the category query appropriately. $CategoryFollowToggleModule = new CategoryFollowToggleModule($this); @@ -616,29 +612,31 @@ public function all($Category = '', $displayAs = '') { true ); } - - if($this->data('CategorySort')) { - if( $this->data('CategorySort') == self::SORT_OLDEST_POST) { - usort($categoryTree, function ($a, $b) { - return Gdn_Format::toTimestamp($a['LastDiscussionCommentsDate']) - Gdn_Format::toTimestamp($b['LastDiscussionCommentsDate']); - }); - - } else if( $this->data('CategorySort') == self::SORT_LAST_POST) { - usort($categoryTree, function ($a, $b) { - return Gdn_Format::toTimestamp($b['LastDiscussionCommentsDate']) - Gdn_Format::toTimestamp($a['LastDiscussionCommentsDate']); - + // FIX: https://github.com/topcoder-platform/forums/issues/422 + // Sorting for Flat type in SQL + if($displayAs != 'Flat') { + if ($this->data('CategorySort')) { + if ($this->data('CategorySort') == self::SORT_OLDEST_POST) { + usort($categoryTree, function ($a, $b) { + return Gdn_Format::toTimestamp($a['LastDiscussionCommentsDate']) - Gdn_Format::toTimestamp($b['LastDiscussionCommentsDate']); + }); + + } else if ($this->data('CategorySort') == self::SORT_LAST_POST) { + usort($categoryTree, function ($a, $b) { + return Gdn_Format::toTimestamp($b['LastDiscussionCommentsDate']) - Gdn_Format::toTimestamp($a['LastDiscussionCommentsDate']); + }); + } + } else { + usort($categoryTree, function ($a, $b) { // desc + return Gdn_Format::toTimestamp($b['LastDiscussionCommentsDate']) - Gdn_Format::toTimestamp($a['LastDiscussionCommentsDate']); }); } - } else { - usort($categoryTree, function ($a, $b) { // desc - return Gdn_Format::toTimestamp($b['LastDiscussionCommentsDate']) - Gdn_Format::toTimestamp($a['LastDiscussionCommentsDate']); - }); } $this->setData('CategoryTree', $categoryTree); // Add modules - if($Category) { + if($Category && $displayAs == 'Discussions') { $this->addModule('NewDiscussionModule'); } $this->addModule('DiscussionFilterModule'); diff --git a/vanilla/applications/vanilla/controllers/class.discussioncontroller.php b/vanilla/applications/vanilla/controllers/class.discussioncontroller.php index e5cfc55..c65e48e 100644 --- a/vanilla/applications/vanilla/controllers/class.discussioncontroller.php +++ b/vanilla/applications/vanilla/controllers/class.discussioncontroller.php @@ -125,9 +125,7 @@ public function index($DiscussionID = '', $DiscussionStub = '', $Page = '') { Gdn_Theme::section($CategoryCssClass); } - $ancestors = CategoryModel::getAncestors($this->CategoryID); - array_unshift ( $ancestors , CategoriesController::ROOT_CATEGORY); - $this->setData('Breadcrumbs', $ancestors); + $this->setData('Breadcrumbs', $this->buildBreadcrumbs($this->CategoryID)); // Setup $this->title($this->Discussion->Name); diff --git a/vanilla/applications/vanilla/controllers/class.discussionscontroller.php b/vanilla/applications/vanilla/controllers/class.discussionscontroller.php index 557163d..e8b9394 100644 --- a/vanilla/applications/vanilla/controllers/class.discussionscontroller.php +++ b/vanilla/applications/vanilla/controllers/class.discussionscontroller.php @@ -115,7 +115,7 @@ public function index($Page = false) { // Add modules $this->addModule('DiscussionFilterModule'); - $this->addModule('NewDiscussionModule'); + // $this->addModule('NewDiscussionModule'); // $this->addModule('CategoriesModule'); $this->addModule('BookmarkedModule'); $this->addModule('TagModule'); @@ -281,7 +281,7 @@ public function unread($page = '0') { // Add modules $this->addModule('DiscussionFilterModule'); - $this->addModule('NewDiscussionModule'); + // $this->addModule('NewDiscussionModule'); // $this->addModule('CategoriesModule'); $this->addModule('BookmarkedModule'); $this->addModule('TagModule'); @@ -459,7 +459,7 @@ public function bookmarked($page = '0') { // Add modules $this->addModule('DiscussionFilterModule'); $this->addModule('NewDiscussionModule'); - // $this->addModule('CategoriesModule'); + // $this->addModule('CategoriesModule'); $this->addModule('TagModule'); // Render default view (discussions/bookmarked.php) @@ -559,7 +559,7 @@ public function mine($page = 'p1') { // Add modules $this->addModule('DiscussionFilterModule'); - $this->addModule('NewDiscussionModule'); + // $this->addModule('NewDiscussionModule'); // $this->addModule('CategoriesModule'); $this->addModule('BookmarkedModule'); $this->addModule('TagModule'); @@ -810,7 +810,7 @@ public function tagged() { } // Add Modules - $this->addModule('NewDiscussionModule'); + // $this->addModule('NewDiscussionModule'); $this->addModule('DiscussionFilterModule'); $this->addModule('BookmarkedModule'); diff --git a/vanilla/applications/vanilla/controllers/class.postcontroller.php b/vanilla/applications/vanilla/controllers/class.postcontroller.php index ed33d7f..3803e90 100644 --- a/vanilla/applications/vanilla/controllers/class.postcontroller.php +++ b/vanilla/applications/vanilla/controllers/class.postcontroller.php @@ -106,12 +106,15 @@ public function announceOptions() { /** * Create or update a discussion. * + * @param string $categoryUrlCode + * @param bool $announce Used for a new discussion only, + * https://github.com/topcoder-platform/forums/issues/444 + * @throws Gdn_UserException * @since 2.0.0 * @access public * - * @param int $categoryID Unique ID of the category to add the discussion to. */ - public function discussion($categoryUrlCode = '') { + public function discussion($categoryUrlCode = '', $announce = '') { // Override CategoryID if categories are disabled $useCategories = $this->ShowCategorySelector = (bool)c('Vanilla.Categories.Use'); if (!$useCategories) { @@ -185,6 +188,9 @@ public function discussion($categoryUrlCode = '') { $this->Form->removeFormValue('DiscussionID'); // Make sure a group discussion doesn't get announced outside the groups category. $formAnnounce = $this->Form->_FormValues['Announce']; + if($announce && $announce == 1) { + $this->Form->setFormValue('Announce', '2'); // Announce in a category only + } // if (isset($formAnnounce) && $formAnnounce === '1') { // if (isset($this->Data['Group'])) { // $this->Form->setFormValue('Announce', '2'); @@ -409,7 +415,7 @@ public function discussion($categoryUrlCode = '') { $this->fireEvent('BeforeDiscussionRender'); if ($this->CategoryID) { - $breadcrumbs = CategoryModel::getAncestors($this->CategoryID); + $breadcrumbs = $this->buildBreadcrumbs($this->CategoryID); } else { $breadcrumbs = []; } @@ -419,7 +425,6 @@ public function discussion($categoryUrlCode = '') { 'Url' => val('AddUrl', val($this->data('Type'), DiscussionModel::discussionTypes()), '/post/discussion') ]; - array_unshift ( $breadcrumbs , CategoriesController::ROOT_CATEGORY); $this->setData('Breadcrumbs', $breadcrumbs); // FIX: Hide Announce options diff --git a/vanilla/applications/vanilla/controllers/class.vanillacontroller.php b/vanilla/applications/vanilla/controllers/class.vanillacontroller.php index e54ef97..ea46859 100644 --- a/vanilla/applications/vanilla/controllers/class.vanillacontroller.php +++ b/vanilla/applications/vanilla/controllers/class.vanillacontroller.php @@ -13,6 +13,8 @@ */ class VanillaController extends Gdn_Controller { + const ROOT_CATEGORY = ['Name' => 'Roundtables', 'Url'=>'/']; + /** * Include JS, CSS, and modules used by all methods. * @@ -70,6 +72,29 @@ protected function checkPageRange(int $offset, int $totalCount) { } } + protected function buildBreadcrumbs($CategoryID) { + $Category = CategoryModel::categories($CategoryID); + $ancestors = CategoryModel::getAncestors($CategoryID); + if(val('GroupID', $Category) > 0) { + $temp = []; + foreach ($ancestors as $id => $ancestor) { + if($ancestor['GroupID'] > 0) { + $temp[$ancestor['CategoryID']] = $ancestor; + } else { + if($ancestor['UrlCode'] == 'challenges-forums') { + array_push($temp, ['Name' => 'Challenge Discussions', 'Url'=>'/groups/mine?filter=challenge']); + }else if($ancestor['UrlCode'] == 'groups') { + array_push($temp, ['Name' => 'Group Discussions', 'Url'=>'/groups/mine?filter=regular']); + } + } + } + return $temp; + } else { + array_unshift($ancestors, self::ROOT_CATEGORY); + return $ancestors; + } + } + protected function log($message, $context = [], $level = Logger::DEBUG) { // if(c('Debug')) { Logger::log($level, sprintf('%s : %s',get_class($this), $message), $context); diff --git a/vanilla/applications/vanilla/js/categoryfilter.js b/vanilla/applications/vanilla/js/categoryfilter.js new file mode 100644 index 0000000..734458d --- /dev/null +++ b/vanilla/applications/vanilla/js/categoryfilter.js @@ -0,0 +1,262 @@ +(function(window, $, document) { + + /** + * Given a template string and an object, replaces any property name contained in curly braces `{}` with the + * corresponding value in the json object. If the property doesn't exist, replaces the property name with an + * empty string. Will only replace the values passed as strings in the replacements array. + * + * @param {Object. } json The object that contains the values we're replacing. + * @param {string} template The template string with the variables to replace. + * @param {string[]} replacements The replacement properties to look for. + * @returns {string} A string with the replacement values replaced. + */ + var renderTemplate = function(json, template, replacements) { + var result = template; + for (var i = 0; i < replacements.length; ++i) { + if (json[replacements[i]] === undefined) { + json[replacements[i]] = ""; + } + if(replacements[i] ==='NameHTML') { + result = result.replace('{escapeHTML(' + replacements[i] + ')}', json[replacements[i]]); + } else { + result = result.replace('{' + replacements[i] + '}', json[replacements[i]]); + } + } + return result; + }; + + /** + * Takes an object of `key: value` attributes and outputs an attributes string to add to an HTML tag. + * + * @param {Object. } attributes A `key: value` object of attributes to transform into a string. + * @returns {string} An string representation of the attributes. + */ + var attrToString = function(attributes) { + if (attributes === undefined) { + return ''; + } + var attrStr = ''; + Object.keys(attributes).forEach(function(key) { + attrStr += key + '="' + attributes[key] + '" '; + }); + + return attrStr; + }; + + /** + * Takes an array of options (retrieved using the CategoriesController's getOptions function) and outputs + * an HTML string representing the options dropdown. + * + * @param {Object. } options The options to render. + * @returns {string} + */ + var categoryOptionsToString = function(options) { + + var itemTemplate = ' \ + \ + {icon}{text} \ + '; + + var headerTemplate = ' \ + '; + + var dropdownTemplate = ' \ + '; + + var menuItems = ''; + var items = options.items; + + for (var key in items) { + if (items.hasOwnProperty(key)) { + var group = items[key]; + if (group.text !== "") { + menuItems += renderTemplate(group, headerTemplate, ['text']); + } + var groupItems = items[key].items; + for (var key in groupItems) { + if (groupItems.hasOwnProperty(key)) { + var item = groupItems[key]; + if (typeof item === 'object') { + item.attributes = attrToString(item.attributes); + var properties = ['cssClass', 'icon', 'text', 'url', 'attributes']; + menuItems += renderTemplate(item, itemTemplate, properties); + } + } + } + + // If this isn't the last item in the list... + if (key !== Object.keys(items)[Object.keys(items).length-1]) { + menuItems += ''; + } + } + } + + options.items = menuItems; + options.text = options.trigger.text; + options.categoryID = options.trigger.attributes['data-id']; + return renderTemplate(options, dropdownTemplate, ['text', 'items', 'categoryID']); + }; + + /** + * Transforms an input box into a category filter box. The input should set the following data attributes: + * + * data-category-id: Which parent category to filter children for. + * data-container: The selector for the container that we display the results in. + * data-hide-container: OPTIONAL If we're toggling showing and hiding categories based on the existance of a + * filter input, set this to be the selector for the container we should hide when the filter exists. + * + * @param {jQuery} $input The form input wrapped in a jQuery object. + */ + var categoryFilter = function($input) { + var categories; + var filteredCategories; + var hideContainerSelector = $input.data('hideContainer'); + var categoryID = $input.data('categoryId'); + var containerSelector = $input.data('container'); + + if (!containerSelector) { + containerSelector = '.category-filter-container'; + } + + var categoryOptions = ' \ +
\ + {Options} \ +
'; + + var categoryTemplate = ' \ +
  • \ +
    \ + {escapeHTML(NameHTML)} \ +
    \ + '+ categoryOptions + '\ +
  • '; + + /** + * Renders the HTML for any category in our filtered list and displays in the container. + */ + var renderCategories = function() { + var replacements = ['NameHTML', 'CategoryID', 'Options']; + $(containerSelector).html(''); + var html = ''; + filteredCategories.forEach(function(category) { + if (category['DisplayAs'] === 'Categories' || category['DisplayAs'] === 'Flat') { + // Wrap in an anchor + category['NameHTML'] = ' \ + \ + ' + category["Name"] + ' \ + '; + } else { + category['NameHTML'] = category['Name']; + } + html += renderTemplate(category, categoryTemplate, replacements); + }); + $(containerSelector).html(html) + }; + + /** + * Filters the categories list for categories whose names contain the filter string. + * Stores these categories in the filteredCategories array. + * + * @param {string} filter The filter to filter category names by. + */ + var filterCategories = function(filter) { + if (filter === undefined) { + filteredCategories = categories; + return; + } + filteredCategories = []; + if (categories !== undefined) { + for (var i = 0; i < categories.length; ++i) { + var name = categories[i]['Name'].toLowerCase(); + filter = filter.toLowerCase(); + if (name.indexOf(filter) !== -1) { + filteredCategories.push(categories[i]); + } + } + } + }; + + /** + * Toggles the display of the container and hide container, if one exists. + */ + var hideContainers = function() { + if (hideContainerSelector !== undefined) { + if ($input.val() === '') { + $(containerSelector).hide(); + $(hideContainerSelector).show(); + } else { + $(containerSelector).show(); + $(hideContainerSelector).hide(); + } + } + }; + + /** + * Updates the category options with new HTML. + * + * @param {Object. } data An object containing the category ID and the new HTML for the options. + */ + var updateCategoryOptionsText = function(data) { + categories.forEach(function(category) { + if (category.CategoryID === data.categoryID) { + category.Options = data.options; + } + }); + }; + + /** + * Do the things we do on page load or when we get a new filter. + * + * @param filter + */ + var go = function(filter) { + filterCategories(filter); + renderCategories(); + }; + + hideContainers(); + + /** + * Fetch the categories. Just once. + */ + (function() { + // fetch our categories + jQuery.get( + gdn.url("categories/getflattenedchildren/" + categoryID), + function(json, textStatus, jqXHR) { + categories = json.Categories; + for (var i = 0; i < categories.length; ++i) { + categories[i].Options = categoryOptionsToString(categories[i].Options); + } + go(); + }, + 'json' + ); + })(); + + $input.on('keyup', function(filterEvent) { + go(filterEvent.target.value); + hideContainers(); + }); + + $(document).on('updateDisplayAs', function(e, data) { + updateCategoryOptionsText(data); + }); + }; + + $(document).on('contentLoad', function(e) { + $(".js-category-filter-input", e.target).each(function() { + categoryFilter($(this)); + }); + }); + +})(window, jQuery, document); diff --git a/vanilla/applications/vanilla/models/class.categorymodel.php b/vanilla/applications/vanilla/models/class.categorymodel.php index e376b1b..73c0f8c 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--; } } @@ -1064,16 +1080,43 @@ public function getTree($categoryID, array $options = []) { * @param string $orderDirection * @return array */ - public function getTreeAsFlat($id, $offset = null, $limit = null, $filter = null, $orderFields = 'Name', $orderDirection = 'asc') { - $query = $this->SQL - ->from('Category') - ->where('DisplayAs <>', 'Heading') - ->where('ParentCategoryID', $id) - ->limit($limit, $offset) + public function getTreeAsFlat($id, $offset = null, $limit = null, $filter = null, $orderFields = 'Name', $orderDirection = 'asc') + { + $query = $this->SQL->from('Category c'); + + $filters = []; + if ($filter && is_string($filter)) { + $filters['Name']= $filter; + } + + if (Gdn::session()->isValid()) { + $filters['UserID'] = Gdn::session()->UserID; + $filters['isAdmin'] = Gdn::session()->User->Admin; + } + + //FIX: https://github.com/topcoder-platform/forums/issues/422 + if (!val('isAdmin', $filters, false)) { + if (val('UserID', $filters, false)) { + $userID = val('UserID', $filters); + $query-> + leftJoin('UserGroup ug', 'c.GroupID = ug.GroupID') + ->beginWhereGroup() + ->where('c.GroupID is null') + ->orWhere('ug.UserID', $userID) + ->endWhereGroup(); + } else { + $query->where('c.GroupID is null'); + } + } + + $query->where('DisplayAs <>', 'Heading') + ->where('ParentCategoryID', $id); + + $query->limit($limit, $offset) ->orderBy($orderFields, $orderDirection); - if ($filter) { - $query->like('Name', $filter); + if (!val('Name', $filters, false)) { + $query->like('Name', $filters['Name']); } $categories = $query->get()->resultArray(); @@ -1082,6 +1125,55 @@ public function getTreeAsFlat($id, $offset = null, $limit = null, $filter = null return $categories; } + /** + * FIX: https://github.com/topcoder-platform/forums/issues/422 + * @param int|string $id The parent category ID or slug. + * @param string|null $filter Restrict results to only those with names matching this value, if provided. + * @return int + * + */ + public function countOfCategories($id, $filter = null) { + $filters = []; + if ($filter && is_string($filter)) { + $filters['Name']= $filter; + } + + if (Gdn::session()->isValid()) { + $filters['UserID'] = Gdn::session()->UserID; + $filters['isAdmin'] = Gdn::session()->User->Admin; + } + + $query = $this->SQL + ->select('c.CategoryID', 'count', 'Count') + ->from('Category c'); + + + if (!val('isAdmin', $filters, false)) { + if (val('UserID', $filters, false)) { + $userID = val('UserID', $filters); + $query-> + leftJoin('UserGroup ug', 'c.GroupID = ug.GroupID') + ->beginWhereGroup() + ->where('c.GroupID is null') + ->orWhere('ug.UserID', $userID) + ->endWhereGroup(); + } else { + $query->where('c.GroupID is null'); + } + } + + $query->where('DisplayAs <>', 'Heading') + ->where('ParentCategoryID', $id); + + if (!val('Name', $filters, false)) { + $query->like('Name', $filters['Name']); + } + + $count = $query->get() + ->firstRow()->Count; + return $count; + } + /** * Recursively remove children from categories configured to display as "Categories" or "Flat". * @@ -1338,8 +1430,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 +1452,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 +1523,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 +1595,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 +1614,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 +1634,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 +1791,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 +1872,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 +1900,7 @@ public function joinRecent(&$categoryTree) { } } + //TODO: bug ? $userIDs[] = ''; // Just gather the users into the local cache. Gdn::userModel()->getIDs($userIDs); @@ -3320,11 +3469,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 +3501,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 +3534,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 +3550,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 +3642,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 +3654,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 +3901,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 +3934,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 +3971,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 +3991,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 +4114,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/applications/vanilla/views/categories/flat_all.php b/vanilla/applications/vanilla/views/categories/flat_all.php new file mode 100644 index 0000000..1b81148 --- /dev/null +++ b/vanilla/applications/vanilla/views/categories/flat_all.php @@ -0,0 +1,42 @@ +fetchViewLocation('helper_functions', 'categories'); + } + $userID = Gdn::session()->UserID; + $categoryID = $this->Category->CategoryID; +?> + +

    data('Title'); ?>

    + +description()) { + // echo wrap($description, 'div', ['class' => 'P PageDescription']); + //} + $this->fireEvent('AfterPageTitle'); + + $categories = $this->data('CategoryTree'); + $this->EventArguments['NumRows'] = count($categories); +?> + +

    +
    + ' 
    %2$s
    ', 'RecordCount' => $this->data('_RecordCount'), 'CurrentRecords' => $this->data('_CurrentRecords')]; + +PagerModule::write($PagerOptions); +?> +
    +
      +EventArguments['Category'] = &$category; + $this->fireEvent('BeforeCategoryItem'); + + writeListItem($category, 1); + } +?> +
    + +
    + +
    diff --git a/vanilla/applications/vanilla/views/discussion/announce.php b/vanilla/applications/vanilla/views/discussion/announce.php new file mode 100644 index 0000000..fe481b6 --- /dev/null +++ b/vanilla/applications/vanilla/views/discussion/announce.php @@ -0,0 +1,21 @@ + + +

    data('Title'); ?>

    + +Form->open(); +echo $this->Form->errors(); + +echo '
    '.t('Where do you want to announce this discussion?').'
    '; + +echo '
    ', $this->Form->radio('Announce', '@'.sprintf(t('In %s.'), $this->data('Category.Name')), ['Value' => '2']), '
    '; +// FIX: https://github.com/topcoder-platform/forums/issues/409 +//echo '
    ', $this->Form->radio('Announce', '@'.sprintf(t('In %s and recent discussions.'), $this->data('Category.Name')), ['Value' => '1']), '
    '; +echo '
    ', $this->Form->radio('Announce', '@'.t("Don't announce."), ['Value' => '0']), '
    '; + +echo '
    '; +echo $this->Form->button('OK'); +echo $this->Form->button('Cancel', ['type' => 'button', 'class' => 'Button Close']); +echo '
    '; +echo $this->Form->close(); +?> diff --git a/vanilla/applications/vanilla/views/discussion/helper_functions.php b/vanilla/applications/vanilla/views/discussion/helper_functions.php index 66ed711..e318b6b 100644 --- a/vanilla/applications/vanilla/views/discussion/helper_functions.php +++ b/vanilla/applications/vanilla/views/discussion/helper_functions.php @@ -336,7 +336,7 @@ function getDiscussionOptionsDropdown($discussion = null) { } $discussionID = $discussion->DiscussionID; - $categoryUrl = urlencode(categoryUrl(CategoryModel::categories($categoryID))); + $categoryUrl = urlencode(categoryUrl(CategoryModel::categories($categoryID),'',false)); // Permissions $canEdit = DiscussionModel::canEdit($discussion, $timeLeft); diff --git a/vanilla/js/flyouts.js b/vanilla/js/flyouts.js new file mode 100644 index 0000000..8fd72fc --- /dev/null +++ b/vanilla/js/flyouts.js @@ -0,0 +1,331 @@ +/** + * Legacy flyout code extracted from global.js + * + * @copyright 2009-2018 Vanilla Forums Inc. + * @license GPL-2.0-only + */ + +/** + * IFFE for flyout code. + * + * @param {Window} window + * @param {jQuery} $ + */ +(function(window, $) { + var USE_NEW_FLYOUTS = gdn.getMeta("useNewFlyouts", false); + var OPEN_CLASS = "Open"; + + /** + * Content load handler, which is fired on first load, and when additional content is loaded in. + */ + $(document).on("contentLoad", function(e) { + kludgeFlyoutHTML(); + }); + + /** + * Document ready handler. Runs only the first time the page is loaded. + */ + $(function() { + $(document).delegate(".Hijack, .js-hijack", "click", handleHijackClick); + $(document).delegate(".ButtonGroup > .Handle", "click", handleButtonHandleClick); + $(document).delegate(".ToggleFlyout", "click", handleToggleFlyoutClick); + $(document).delegate(".ToggleFlyout a, .Dropdown a", "mouseup", handleToggleFlyoutMouseUp); + $(document).delegate(".mobileFlyoutOverlay", "click", function (e) { + e.preventDefault(); + e.stopPropagation(); + closeAllFlyouts(); + }); + $(document).delegate(".Flyout, .Dropdown", "click", function (e) { + e.stopPropagation(); + }); + $(document).on("click", function (e) { + closeAllFlyouts(); + }) + }); + + /** + * Workarounds for limitations of flyout's HTML structure. + */ + function kludgeFlyoutHTML() { + var $handles = $(".ToggleFlyout, .editor-dropdown, .ButtonGroup"); + + $handles.each(function() { + $handles + .find(".FlyoutButton, .Button-Options, .Handle, .editor-action:not(.editor-action-separator)") + .each(function() { + $(this) + .attr("tabindex", "0") + .attr("role", "button") + .attr("aria-haspopup", "true"); + + $(this).accessibleFlyoutHandle(false); + }); + + $handles.find(".Flyout, .Dropdown").each(function() { + $(this).accessibleFlyout(false); + + $(this) + .find("a") + .each(function() { + $(this).attr("tabindex", "0"); + }); + }); + }); + + if (USE_NEW_FLYOUTS) { + var $contents = $(".Flyout, .ButtonGroup .Dropdown"); + var wrap = document.createElement("span"); + wrap.classList.add("mobileFlyoutOverlay"); + + $contents.each(function() { + var $item = $(this); + if (!this.parentElement.classList.contains("mobileFlyoutOverlay")) { + $item.wrap(wrap); + } + + // Some flyouts had conflicting inline display: none directly in the view. + // We don't change that on open/close with the new style anymore so let's clean it up here. + $item.removeAttr("style"); + }); + } + + // Button accessibility + $(document).delegate("[role=button]", "keydown", function(event) { + var $button = $(this); + var ENTER_KEY = 13; + var SPACE_KEY = 32; + var isActiveElement = document.activeElement === $button[0]; + var isSpaceOrEnter = event.keyCode === ENTER_KEY || event.keyCode === SPACE_KEY; + if (isActiveElement && isSpaceOrEnter) { + event.preventDefault(); + $button.click(); + } + }); + } + + var BODY_CLASS = "flyoutIsOpen"; + + /** + * Close all flyouts and open the specified one. + * + * @param {JQuery} $toggleFlyout The flyout handle + * @param {JQuery} $flyout The flyout body. + */ + function openFlyout($toggleFlyout, $flyout) { + closeAllFlyouts(); + + $toggleFlyout + .addClass(OPEN_CLASS) + .closest(".Item") + .addClass(OPEN_CLASS); + + if (!USE_NEW_FLYOUTS) { + $flyout.show(); + } + $toggleFlyout.setFlyoutAttributes(); + document.body.classList.add(BODY_CLASS); + $toggleFlyout.trigger('OpenFlyout', [$toggleFlyout]); + } + + /** + * Close the specified flyout. + * + * @param {JQuery} $toggleFlyout The flyout handle + * @param {JQuery} $flyout The flyout body. + */ + function closeFlyout($toggleFlyout, $flyout) { + if (!USE_NEW_FLYOUTS) { + $flyout.hide(); + } + $toggleFlyout + .removeClass(OPEN_CLASS) + .closest(".Item") + .removeClass(OPEN_CLASS); + $toggleFlyout.setFlyoutAttributes(); + document.body.classList.remove(BODY_CLASS); + $toggleFlyout.trigger('CloseFlyout', [ $toggleFlyout]); + } + + /** + * Close all flyouts, including ButtonGroups. + */ + function closeAllFlyouts(e) { + closeFlyout($(".ToggleFlyout"), $(".Flyout")); + // Clear the button groups that are open as well. + $(".ButtonGroup") + .removeClass(OPEN_CLASS) + .setFlyoutAttributes(); + + // Kludge for legacy editor. + $(".editor-dropdown-open") + .removeClass("editor-dropdown-open") + .setFlyoutAttributes(); + document.body.classList.remove(BODY_CLASS); + } + + window.closeAllFlyouts = closeAllFlyouts; + + /** + * Take over the clicking of an element in order to make a post request. + * + * @param {MouseEvent} e The click event. + */ + function handleHijackClick(e) { + var $elem = $(this); + var $parent = $(this).closest(".Item"); + var $toggleFlyout = $elem.closest(".ToggleFlyout"); + var href = $elem.attr("href"); + var progressClass = $elem.hasClass("Bookmark") ? "Bookmarking" : "InProgress"; + + // If empty, or starts with a fragment identifier, do not send + // an async request. + if (!href || href.trim().indexOf("#") === 0) return; + gdn.disable(this, progressClass); + e.stopPropagation(); + + $.ajax({ + type: "POST", + url: href, + data: { DeliveryType: "VIEW", DeliveryMethod: "JSON", TransientKey: gdn.definition("TransientKey") }, + dataType: "json", + complete: function() { + gdn.enable($elem.get(0)); + $elem.removeClass(progressClass); + $elem.attr("href", href); + $flyout = $toggleFlyout.find(".Flyout"); + closeFlyout($toggleFlyout, $flyout); + }, + error: function(xhr) { + gdn.informError(xhr); + }, + success: function(json) { + if (json === null) json = {}; + + var informed = gdn.inform(json); + gdn.processTargets(json.Targets, $elem, $parent); + // If there is a redirect url, go to it. + if (json.RedirectTo) { + setTimeout(function() { + window.location.replace(json.RedirectTo); + }, informed ? 3000 : 0); + } + }, + }); + + return false; + } + + /** + * Close existing flyouts and dropdowns and open the dropdown for a particular button handle. + */ + function handleButtonHandleClick() { + var $buttonGroup = $(this).closest(".ButtonGroup"); + var $isOpen = $buttonGroup.hasClass(OPEN_CLASS); + closeAllFlyouts(); + if (!$isOpen) { + // Open this one + $buttonGroup.addClass(OPEN_CLASS).setFlyoutAttributes(); + } + return false; + } + + /** + * Handle clicks on the flyout. + * + * @param {MouseEvent} e The click event to handle. + */ + function handleToggleFlyoutClick(e) { + var $toggleFlyout = $(this); + var $flyout = $(".Flyout", this); + var isHandle = false; + + if ($(e.target).closest(".Flyout").length === 0) { + isHandle = true; + e.stopPropagation(); + } else if ( + $(e.target).hasClass("Hijack") || + $(e.target) + .closest("a") + .hasClass("Hijack") + ) { + return; + } + e.stopPropagation(); + $toggleFlyout.fillFlyoutDynamically(); + + // The old check. + var isFlyoutClosed = $flyout.css("display") == "none"; + if (USE_NEW_FLYOUTS) { + // The new check. + isFlyoutClosed = !$toggleFlyout.hasClass(OPEN_CLASS); + } + + // Toggling. + if (isFlyoutClosed) { + openFlyout($toggleFlyout, $flyout); + } else { + closeFlyout($toggleFlyout, $flyout); + } + + if (isHandle) return false; + } + + /** + * Close all of the flyouts unless we are clicking on a button inside of a flyout. + */ + function handleToggleFlyoutMouseUp() { + if ($(this).hasClass("FlyoutButton")) return; + closeAllFlyouts(); + } + + /** + * jQuery function extensions + */ + $.fn.extend({ + fillFlyoutDynamically: function() { + var rel = $(this).attr("rel"); + if (rel) { + $flyout = $(this).find(".Flyout"); + + // Clear the rel and set a progress indicator. + $(this).attr("rel", ""); + $flyout.html('
    '); + + // Fetch the contents dynamically and fill on contents of the flyout. + $.ajax({ + url: gdn.url(rel), + data: { DeliveryType: "VIEW" }, + success: function(data) { + $flyout.html(data); + }, + error: function(xhr) { + $flyout.html(""); + gdn.informError(xhr, true); + }, + }); + } + }, + accessibleFlyoutHandle: function(isOpen) { + $(this).attr("aria-expanded", isOpen.toString()); + }, + + accessibleFlyout: function(isOpen) { + $(this).attr("aria-hidden", (!isOpen).toString()); + }, + + setFlyoutAttributes: function() { + $toggleFlyouts = $(this); + $toggleFlyouts.each(function() { + $toggle = $(this); + var $handle = $(this).find( + ".FlyoutButton, .Button-Options, .Handle, .editor-action:not(.editor-action-separator)" + ); + var $flyout = $(this).find(".Flyout, .Dropdown"); + var isOpen = $toggle.hasClass(OPEN_CLASS); + + $handle.accessibleFlyoutHandle(isOpen); + $flyout.accessibleFlyout(isOpen); + }); + }, + }); +})(window, jQuery); 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; + } +} diff --git a/vanilla/library/core/functions.render.php b/vanilla/library/core/functions.render.php index c488c05..4a7f747 100644 --- a/vanilla/library/core/functions.render.php +++ b/vanilla/library/core/functions.render.php @@ -1723,6 +1723,12 @@ function signInUrl($target = '', $force = false) { } } + // FIX: https://github.com/topcoder-platform/forums/issues/383 + // The '' and the default route navigates to a home page + if ($target === c('Routes.DefaultController')) { + $target = '' ; + } + return '/entry/signin'.($target ? '?Target='.urlencode($target) : ''); } }