diff --git a/config/vanilla/bootstrap.early.php b/config/vanilla/bootstrap.early.php index 53a4044..c81bdcd 100644 --- a/config/vanilla/bootstrap.early.php +++ b/config/vanilla/bootstrap.early.php @@ -51,8 +51,9 @@ saveToConfig('Plugins.Topcoder.SSO.Auth0Domain', getenv('TOPCODER_PLUGIN_SSO_AUTH0DOMAIN')); saveToConfig('Plugins.Topcoder.SSO.AuthorizationURI', '/v3/authorizations/1'); saveToConfig('Plugins.Topcoder.SSO.CookieName', 'v3jwt',false); - saveToConfig('Plugins.Topcoder.SSO.TopcoderRS256.ID', getenv('TOPCODER_PLUGIN_SSO_TOPCODER_RS256_ID'), 'BXWXUWnilVUPdN01t2Se29Tw2ZYNGZvH'); - saveToConfig('Plugins.Topcoder.SSO.TopcoderHS256.ID', getenv('TOPCODER_PLUGIN_SSO_TOPCODER_HS256_ID'), 'JFDo7HMkf0q2CkVFHojy3zHWafziprhT'); + saveToConfig('Plugins.Topcoder.SSO.TopcoderRS256.ID', getenv('TOPCODER_PLUGIN_SSO_TOPCODER_RS256_ID'), false); + saveToConfig('Plugins.Topcoder.SSO.TopcoderHS256.ID', getenv('TOPCODER_PLUGIN_SSO_TOPCODER_HS256_ID'), false); + saveToConfig('Plugins.Topcoder.SSO.TopcoderHS256.Secret', getenv('TOPCODER_HS256_SECRET') ); saveToConfig('Plugins.Topcoder.SSO.TopcoderRS256.UsernameClaim', 'nickname',false); saveToConfig('Plugins.Topcoder.SSO.TopcoderHS256.UsernameClaim', 'handle',false); @@ -96,27 +97,6 @@ saveToConfig('Recaptcha.PublicKey', getenv('RECAPTCHA_PLUGIN_PUBLIC_KEY'), false); } - - // Fix: OAuth 2 SSO should be inactive and not by default. It should be removed later. - if ($SQL->getWhere('UserAuthenticationProvider', ['AuthenticationKey' => 'oauth2'])->numRows() > 0) { - $SQL->update('UserAuthenticationProvider') - ->set('Active', 0) - ->set('IsDefault',0) - ->where('AuthenticationKey' , 'oauth2')->put(); - } - - // Add Topcoder User Authentication Provider. - // SignInUrl/SignOutUrl should be set in Topcoder plugin's setup; otherwise they couldn't be updated in DB - if ($SQL->getWhere('UserAuthenticationProvider', ['AuthenticationKey' => 'topcoder'])->numRows() == 0) { - $SQL->insert('UserAuthenticationProvider', [ - 'AuthenticationKey' => 'topcoder', - 'AuthenticationSchemeAlias' => 'topcoder', - 'Name' => 'topcoder', - 'Active' => 1, - 'IsDefault' => 1 - ]); - } - // Fix: Add the 'topcoder' role type in Role Table. It should be removed after upgrading existing DB. // The Topcoder plugin's setup method will upgrade DB during Vanilla installation $SQL->query('alter table GDN_Role modify Type enum(\'topcoder\', \'guest\', \'unconfirmed\', \'applicant\', \'member\', \'moderator\', \'administrator\')'); diff --git a/vanilla/applications/dashboard/models/class.permissionmodel.php b/vanilla/applications/dashboard/models/class.permissionmodel.php new file mode 100644 index 0000000..d03192c --- /dev/null +++ b/vanilla/applications/dashboard/models/class.permissionmodel.php @@ -0,0 +1,1480 @@ + $value) { + $newValues['`'.$key.'`'] = $value; + } + return $newValues; + } + + /** + * Add an entry into the list of default permissions. + * + * @param string $type Type of role the permissions should be added for. + * @param array $permissions The list of permissions to include. + * @param null|string $junction Type of junction to base the permission on. + * @param null|int $junctionId Identifier for the specific junction record to base the permission on. + */ + public function addDefault($type, $permissions, $junction = null, $junctionId = null) { + if (!array_key_exists($type, $this->DefaultPermissions)) { + $this->DefaultPermissions[$type] = ['global' => []]; + } + + if ($junction && $junctionId) { + $junctionKey = "$junction:$junctionId"; + if (!array_key_exists($junctionKey, $this->DefaultPermissions[$type])) { + $this->DefaultPermissions[$type][$junctionKey] = []; + } + $defaults =& $this->DefaultPermissions[$type][$junctionKey]; + } else { + $defaults =& $this->DefaultPermissions[$type]['global']; + } + + $defaults = array_merge($defaults, $permissions); + } + + /** + * Add the permissions from one permission array to another. + * + * @param array $perms1 The permissions to be added to. + * @param array $perms2 The permissions to add. + * @return array Returns an array with all of the permissions in both permissions arrays. + */ + public static function addPermissions($perms1, $perms2) { + // Union the global permissions. + $result = array_unique(array_merge(array_filter($perms1, 'is_string'), array_filter($perms2, 'is_string'))); + + // Union the junction permissions. + $junctions1 = array_filter($perms1, 'is_array'); + $junctions2 = array_filter($perms2, 'is_array'); + foreach ($junctions2 as $key => $ids) { + if (empty($junctions1[$key])) { + $junctions1[$key] = $ids; + } else { + $junctions1[$key] = array_unique(array_merge($junctions1[$key], $ids)); + } + } + + $result = array_merge($result, $junctions1); + return $result; + } + + /** + * Populate a list of default permissions, per type. + * + * @param bool $resetDefaults If we already have defaults, should they be discarded? + */ + public function assignDefaults($resetDefaults = false) { + if (count($this->DefaultPermissions)) { + if ($resetDefaults) { + $this->DefaultPermissions = []; + } else { + return; + } + } + + $this->addDefault( + RoleModel::TYPE_GUEST, + [ + 'Garden.Activity.View' => 1, + 'Garden.Profiles.View' => 1, + 'Garden.Uploads.Add' => 0 + ] + ); + $this->addDefault( + RoleModel::TYPE_UNCONFIRMED, + $permissions = [ + 'Garden.SignIn.Allow' => 1, + 'Garden.Activity.View' => 1, + 'Garden.Profiles.View' => 1, + 'Garden.Email.View' => 1, + 'Garden.Uploads.Add' => 0 + ] + ); + $this->addDefault( + RoleModel::TYPE_APPLICANT, + $permissions = [ + 'Garden.SignIn.Allow' => 1, + 'Garden.Activity.View' => 1, + 'Garden.Profiles.View' => 1, + 'Garden.Email.View' => 1, + 'Garden.Uploads.Add' => 0 + ] + ); + $this->addDefault( + RoleModel::TYPE_MODERATOR, + $permissions = [ + 'Garden.SignIn.Allow' => 1, + 'Garden.Activity.View' => 1, + 'Garden.Curation.Manage' => 1, + 'Garden.Moderation.Manage' => 1, + 'Garden.PersonalInfo.View' => 1, + 'Garden.Profiles.View' => 1, + 'Garden.Profiles.Edit' => 1, + 'Garden.Email.View' => 1, + 'Garden.Uploads.Add' => 1 + ] + ); + $this->addDefault( + RoleModel::TYPE_ADMINISTRATOR, + [ + 'Garden.SignIn.Allow' => 1, + 'Garden.Settings.View' => 1, + 'Garden.Settings.Manage' => 1, + 'Garden.Community.Manage' => 1, + 'Garden.Users.Add' => 1, + 'Garden.Users.Edit' => 1, + 'Garden.Users.Delete' => 1, + 'Garden.Users.Approve' => 1, + 'Garden.Activity.Delete' => 1, + 'Garden.Activity.View' => 1, + 'Garden.Messages.Manage' => 1, + 'Garden.PersonalInfo.View' => 1, + 'Garden.Profiles.View' => 1, + 'Garden.Profiles.Edit' => 1, + 'Garden.AdvancedNotifications.Allow' => 1, + 'Garden.Email.View' => 1, + 'Garden.Curation.Manage' => 1, + 'Garden.Moderation.Manage' => 1, + 'Garden.Uploads.Add' => 1 + ] + ); + $this->addDefault( + RoleModel::TYPE_MEMBER, + [ + 'Garden.SignIn.Allow' => 1, + 'Garden.Activity.View' => 1, + 'Garden.Profiles.View' => 1, + 'Garden.Profiles.Edit' => 1, + 'Garden.Email.View' => 1, + 'Garden.Uploads.Add' => 1 + ] + ); + + $this->addDefault( + RoleModel::TYPE_TOPCODER, + [ + 'Garden.SignIn.Allow' => 1, + ] + ); + + // Allow the ability for other applications and plug-ins to speak up with their own default permissions. + $this->fireEvent('DefaultPermissions'); + } + + /** + * Remove the cached permissions for all users. + */ + public function clearPermissions() { + static $permissionsCleared = false; + + if (!$permissionsCleared) { + Gdn::userModel()->clearPermissions(); + $permissionsCleared = true; + } + } + + /** + * Define one or more permissions with default values. + * + * @param array $permissionNames + * @param string $type + * @param string? $junctionTable + * @param string? $junctionColumn + * @throws Exception + */ + public function define($permissionNames, $type = 'tinyint', $junctionTable = null, $junctionColumn = null) { + if (!is_array($permissionNames)) { + trigger_error(__CLASS__.'->'.__METHOD__.' was called with an invalid $PermissionNames parameter.', E_USER_ERROR); + return; + } + + $structure = $this->Database->structure(); + $structure->table('Permission'); + $defaultPermissions = []; + $newColumns = []; + + foreach ($permissionNames as $key => $value) { + if (is_numeric($key)) { + // Only got a permissions name with no default. + $permissionName = $value; + $defaultPermissions[$permissionName] = 2; + } else { + $permissionName = $key; + + if ($value === 0) { + // "Off for all" + $defaultPermissions[$permissionName] = 2; + } elseif ($value === 1) + // "On for all" + $defaultPermissions[$permissionName] = 3; + elseif (!$structure->columnExists($value) && array_key_exists($value, $permissionNames)) + // Mapped to an explicitly-defined permission. + $defaultPermissions[$permissionName] = $permissionNames[$value] ? 3 : 2; + else { + // Mapped to another permission for which we don't have the value. + $defaultPermissions[$permissionName] = "`{$value}`"; + } + } + if (!$structure->columnExists($permissionName)) { + $default = $defaultPermissions[$permissionName]; + $newColumns[$permissionName] = is_numeric($default) ? $default - 2 : $default; + } + + // Define the column. + $structure->column($permissionName, $type, 0); + + } + $structure->set(false, false); + + // Detect an initial permission setup by seeing if our placeholder row exists yet. + $defaultRow = $this->SQL + ->select('*') + ->from('Permission') + ->where('RoleID', 0) + ->where('JunctionTable is null') + ->orderBy('RoleID') + ->limit(1) + ->get()->firstRow(DATASET_TYPE_ARRAY); + + // If this is our initial setup, map missing permissions to off. + // Otherwise we'd be left with placeholders in our final query, which would cause a strict mode failure. + if (!$defaultRow) { + $defaultPermissions = array_map( + function ($value) { + // All non-numeric values are converted to "off" flag. + return (is_numeric($value)) ? $value : 2; + }, + $defaultPermissions + ); + } + + // Set the default permissions on the placeholder. + $this->SQL + ->set($this->_backtick($defaultPermissions), '', false) + ->replace('Permission', [], ['RoleID' => 0, 'JunctionTable' => $junctionTable, 'JunctionColumn' => $junctionColumn], true); + + // Set the default permissions for new columns on all roles. + if (count($newColumns) > 0) { + $where = ['RoleID <>' => 0]; + if (!$junctionTable) { + $where['JunctionTable'] = null; + } else { + $where['JunctionTable'] = $junctionTable; + } + + $this->SQL + ->set($this->_backtick($newColumns), '', false) + ->put('Permission', [], $where); + } + + // Flush permissions cache & loaded schema. + $this->clearPermissions(); + if ($this->Schema) { + // Redefine the schema if it has been defined to reflect the permissions that were just added. + $this->Schema = null; + $this->defineSchema(); + } + } + + /** + * + * + * @param null $roleID + * @param null $junctionTable + * @param null $junctionColumn + * @param null $junctionID + */ + public function delete($roleID = null, $junctionTable = null, $junctionColumn = null, $junctionID = null) { + // Build the where clause. + $where = []; + if (!is_null($roleID)) { + $where['RoleID'] = $roleID; + } + if (!is_null($junctionTable)) { + $where['JunctionTable'] = $junctionTable; + $where['JunctionColumn'] = $junctionColumn; + $where['JunctionID'] = $junctionID; + } + + $this->SQL->delete('Permission', $where); + + if (!is_null($roleID)) { + // Rebuild the permission cache. + } + } + + /** + * Grab the list of default permissions by role type + * + * @return array List of permissions, grouped by role type + */ + public function getDefaults() { + if (empty($this->DefaultPermissions)) { + $this->assignDefaults(); + } + + return $this->DefaultPermissions; + } + + /** + * Grab default permission column values. + * + * @throws Exception Throws when no default permission row can be found in the database. + * @return array A list of default permission values. + */ + public function getRowDefaults() { + if (empty($this->RowDefaults)) { + $defaultRow = $this->SQL + ->select('*') + ->from('Permission') + ->where('RoleID', 0) + ->where('JunctionTable is null') + ->orderBy('RoleID') + ->limit(1) + ->get()->firstRow(DATASET_TYPE_ARRAY); + + if (!$defaultRow) { + throw new Exception(t('No default permission row.')); + } + + $this->_MergeDisabledPermissions($defaultRow); + + unset( + $defaultRow['PermissionID'], + $defaultRow['RoleID'], + $defaultRow['JunctionTable'], + $defaultRow['JunctionColumn'], + $defaultRow['JunctionID'] + ); + + $this->RowDefaults = $this->stripPermissions($defaultRow, $defaultRow); + } + + return $this->RowDefaults; + } + + /** + * Get the permissions of a user. + * + * If no junction table is specified, will return ONLY non-junction permissions. + * If you need every permission regardless of junction & suffix, see CachePermissions. + * + * @param int $userID Unique identifier for user. + * @param string $limitToSuffix String permission name must match, starting on right (ex: 'View' would match *.*.View) + * @param string $junctionTable Optionally limit returned permissions to 1 junction (ex: 'Category'). + * @param string $junctionColumn Column to join junction table on (ex: 'CategoryID'). Required if using $junctionTable. + * @param string $foreignKey Foreign table column to join on. + * @param int $foreignID Foreign ID to limit join to. + * @return array Permission records. + */ + public function getUserPermissions($userID, $limitToSuffix = '', $junctionTable = false, $junctionColumn = false, $foreignKey = false, $foreignID = false) { + // Get all permissions + $permissionColumns = $this->permissionColumns($junctionTable, $junctionColumn); + + // Select any that match $LimitToSuffix + foreach ($permissionColumns as $columnName => $value) { + if (!empty($limitToSuffix) && substr($columnName, -strlen($limitToSuffix)) != $limitToSuffix) { + continue; // permission not in $LimitToSuffix + } $this->SQL->select('p.`'.$columnName.'`', 'MAX'); + } + + // Generic part of query + $this->SQL->from('Permission p') + ->join('UserRole ur', 'p.RoleID = ur.RoleID') + ->where('ur.UserID', $userID); + + // Either limit to 1 junction or exclude junctions + if ($junctionTable && $junctionColumn) { + $this->SQL + ->select(['p.JunctionTable', 'p.JunctionColumn', 'p.JunctionID']) + ->groupBy(['p.JunctionTable', 'p.JunctionColumn', 'p.JunctionID']); + if ($foreignKey && $foreignID) { + $this->SQL + ->join("$junctionTable j", "j.$junctionColumn = p.JunctionID") + ->where("j.$foreignKey", $foreignID); + } + } else { + $this->SQL->where('p.JunctionTable is null'); + } + + return $this->SQL->get()->resultArray(); + } + + /** + * Get the permissions of a role. + * + * If no junction table is specified, will return ONLY non-junction permissions. + * If you need every permission regardless of junction & suffix, see CachePermissions. + * + * @param int $roleID Unique identifier for role. + * @param string $limitToSuffix String permission name must match, starting on right (ex: 'View' would match *.*.View) + * @param string $junctionTable Optionally limit returned permissions to 1 junction (ex: 'Category'). + * @param string $junctionColumn Column to join junction table on (ex: 'CategoryID'). Required if using $junctionTable. + * @param string $foreignKey Foreign table column to join on. + * @param int $foreignID Foreign ID to limit join to. + * @return array Permission records. + */ + public function getRolePermissions($roleID, $limitToSuffix = '', $junctionTable = false, $junctionColumn = false, $foreignKey = false, $foreignID = false) { + // Get all permissions + $permissionColumns = $this->permissionColumns($junctionTable, $junctionColumn); + + // Select any that match $LimitToSuffix + foreach ($permissionColumns as $columnName => $value) { + if (!empty($limitToSuffix) && substr($columnName, -strlen($limitToSuffix)) != $limitToSuffix) { + continue; // permission not in $LimitToSuffix + } $this->SQL->select('p.`'.$columnName.'`', 'MAX'); + } + + // Generic part of query + $this->SQL->from('Permission p') + ->where('p.RoleID', $roleID); + + // Either limit to 1 junction or exclude junctions + if ($junctionTable && $junctionColumn) { + $this->SQL + ->select(['p.JunctionTable', 'p.JunctionColumn', 'p.JunctionID']) + ->groupBy(['p.JunctionTable', 'p.JunctionColumn', 'p.JunctionID']); + if ($foreignKey && $foreignID) { + $this->SQL + ->join("$junctionTable j", "j.$junctionColumn = p.JunctionID") + ->where("j.$foreignKey", $foreignID); + } + } else { + $this->SQL->where('p.JunctionTable is null'); + } + + return $this->SQL->get()->resultArray(); + } + + /** + * Returns a complete list of all enabled applications & plugins. This list + * can act as a namespace list for permissions. + * + * @return array + */ + public function getAllowedPermissionNamespaces() { + $applicationManager = Gdn::applicationManager(); + $enabledApplications = $applicationManager->enabledApplications(); + + $pluginNamespaces = []; + foreach (Gdn::pluginManager()->enabledPlugins() as $plugin) { + if (!array_key_exists('RegisterPermissions', $plugin) || !is_array($plugin['RegisterPermissions'])) { + continue; + } + foreach ($plugin['RegisterPermissions'] as $index => $permissionName) { + if (is_string($index)) { + $permissionName = $index; + } + + $namespace = substr($permissionName, 0, strrpos($permissionName, '.')); + $pluginNamespaces[$namespace] = true; + } + } + + $result = array_merge(array_keys($enabledApplications), array_keys($pluginNamespaces)); + if (in_array('Dashboard', $result)) { + $result[] = 'Garden'; + } + return $result; + } + + /** + * + * + * @param null $userID + * @param null $roleID + * @return array|null + */ + public function cachePermissions($userID = null, $roleID = null) { + if (!$userID) { + $roleID = RoleModel::getDefaultRoles(RoleModel::TYPE_GUEST); + } + + // Select all of the permission columns. + $permissionColumns = $this->permissionColumns(); + foreach ($permissionColumns as $columnName => $value) { + $this->SQL->select('p.`'.$columnName.'`', 'MAX'); + } + + $this->SQL->from('Permission p'); + + if (!is_null($roleID)) { + $this->SQL->where('p.RoleID', $roleID); + } elseif (!is_null($userID)) + $this->SQL->join('UserRole ur', 'p.RoleID = ur.RoleID')->where('ur.UserID', $userID); + + $this->SQL + ->select(['p.JunctionTable', 'p.JunctionColumn', 'p.JunctionID']) + ->groupBy(['p.JunctionTable', 'p.JunctionColumn', 'p.JunctionID']); + + $result = $this->SQL->get()->resultArray(); + return $result; + } + + /** + * + * + * @param $where + * @param null $junctionTable + * @param string $limitToSuffix + * @param array $options + * @return array + */ + public function getJunctionPermissions($where, $junctionTable = null, $limitToSuffix = '', $options = []) { + $namespaces = $this->getNamespaces(); + $roleID = val('RoleID', $where, null); + $junctionID = val('JunctionID', $where, null); + $limitToDefault = val('LimitToDefault', $options); + $sQL = $this->SQL; + + // Load all of the default junction permissions. + $sQL->select('*') + ->from('Permission p') + ->where('p.RoleID', 0); + + if (is_null($junctionTable)) { + $sQL->where('p.JunctionTable is not null'); + } else { + $sQL->where('p.JunctionTable', $junctionTable); + } + + // Get the disabled permissions. + $disabledPermissions = c('Garden.Permissions.Disabled'); + if (is_array($disabledPermissions)) { + $disabledWhere = []; + foreach ($disabledPermissions as $tableName => $disabled) { + if ($disabled) { + $disabledWhere[] = $tableName; + } + } + if (count($disabledWhere) > 0) { + $sQL->whereNotIn('JunctionTable', $disabledWhere); + } + } + + $data = $sQL->get()->resultArray(); + $result = []; + foreach ($data as $row) { + $junctionTable = $row['JunctionTable']; + $junctionColumn = $row['JunctionColumn']; + unset($row['PermissionID'], $row['RoleID'], $row['JunctionTable'], $row['JunctionColumn'], $row['JunctionID']); + + // If the junction column is not the primary key then we must figure out and limit the permissions. + if ($limitToDefault === false && $junctionColumn != $junctionTable.'ID') { + $juncIDs = $sQL + ->distinct(true) + ->select("p.{$junctionTable}ID") + ->select("c.$junctionColumn") + ->select('p.Name') + ->from("$junctionTable c") + ->join("$junctionTable p", "c.$junctionColumn = p.{$junctionTable}ID", 'left') + ->get()->resultArray(); + + foreach ($juncIDs as &$juncRow) { + if (!$juncRow[$junctionTable.'ID']) { + $juncRow[$junctionTable.'ID'] = -1; + } + } + } + + if (!empty($roleID) || !empty($junctionID)) { + // Figure out which columns to select. + foreach ($row as $permissionName => $value) { + if (!($value & 2)) { + continue; // permission not applicable to this junction table + } + if (!empty($limitToSuffix) && substr($permissionName, -strlen($limitToSuffix)) != $limitToSuffix) { + continue; // permission not in $LimitToSuffix + } + if ($index = strpos($permissionName, '.')) { + if (!in_array(substr($permissionName, 0, $index), $namespaces) && + !in_array(substr($permissionName, 0, strrpos($permissionName, '.')), $namespaces) + ) { + continue; // permission not in allowed namespaces + } + } + + // If we are viewing the permissions by junction table (ex. Category) then set the default value when a permission row doesn't exist. + if (!$roleID && $junctionColumn != $junctionTable.'ID' && val('AddDefaults', $options)) { + $defaultValue = $value & 1 ? 1 : 0; + } else { + $defaultValue = 0; + } + + $sQL->select("p.`$permissionName`, $defaultValue", 'coalesce', $permissionName); + } + + if (!empty($roleID)) { + $roleIDs = (array)$roleID; + if (count($roleIDs) === 1) { + $roleOn = 'p.RoleID = '.$this->SQL->Database->connection()->quote(reset($roleIDs)); + } else { + $roleIDs = array_map([$this->SQL->Database->connection(), 'quote'], $roleIDs); + $roleOn = 'p.RoleID in ('.implode(',', $roleIDs).')'; + } + + // Get the permissions for the junction table. + $sQL->select('junc.Name') + ->select('junc.'.$junctionColumn, '', 'JunctionID') + ->from($junctionTable.' junc') + ->join('Permission p', "p.JunctionID = junc.$junctionColumn and $roleOn", 'left') + ->orderBy('junc.Sort') + ->orderBy('junc.Name'); + + if ($limitToDefault) { + $sQL->where("junc.{$junctionTable}ID", -1); + } elseif (isset($juncIDs)) { + $sQL->whereIn("junc.{$junctionTable}ID", array_column($juncIDs, "{$junctionTable}ID")); + } + + $juncData = $sQL->get()->resultArray(); + } elseif (!empty($junctionID)) { + // Here we are getting permissions for all roles. + $juncData = $sQL->select('r.RoleID, r.Name, r.CanSession') + ->from('Role r') + ->join('Permission p', "p.RoleID = r.RoleID and p.JunctionTable = '$junctionTable' and p.JunctionColumn = '$junctionColumn' and p.JunctionID = $junctionID", 'left') + ->orderBy('r.Sort, r.Name') + ->get()->resultArray(); + } + } else { + $juncData = []; + } + + // Add all of the necessary information back to the result. + foreach ($juncData as $juncRow) { + $juncRow['JunctionTable'] = $junctionTable; + $juncRow['JunctionColumn'] = $junctionColumn; + if (!is_null($junctionID)) { + $juncRow['JunctionID'] = $junctionID; + } + if ($juncRow['JunctionID'] < 0) { + $juncRow['Name'] = sprintf(t('Default %s Permissions'), t('Permission.'.$junctionTable, $junctionTable)); + } + + if (array_key_exists('CanSession', $juncRow)) { + if (!$juncRow['CanSession']) { + // Remove view permissions. + foreach ($juncRow as $permissionName => $value) { + if (strpos($permissionName, '.') !== false && strpos($permissionName, '.View') === false) { + unset($juncRow[$permissionName]); + } + } + } + + unset($juncRow['CanSession']); + } + + if (!$roleID && !$junctionID && array_key_exists(0, $data)) { + // Set all of the default permissions for a new role. + foreach ($juncRow as $permissionName => $value) { + if (val($permissionName, $data[0], 0) & 1) { + $juncRow[$permissionName] = 1; + } + } + } + + $result[] = $juncRow; + } + } + return $result; + } + + /** + * Returns all defined permissions not related to junction tables. Excludes + * permissions related to applications & plugins that are disabled. + * + * @param int|array $roleID The role(s) to get the permissions for. + * @param string $limitToSuffix An optional suffix to limit the permission names to. + * @param bool $includeJunction + * @return array + */ + public function getPermissions($roleID, $limitToSuffix = '', $includeJunction = true) { + $roleID = (array)$roleID; + $result = []; + + $globalPermissions = $this->getGlobalPermissions($roleID, $limitToSuffix); + $result[] = $globalPermissions; + + $junctionOptions = []; + if ($includeJunction === false) { + // If we're skipping junction permissions, just grab the defaults. + $junctionOptions['LimitToDefault'] = true; + } + $junctionPermissions = $this->getJunctionPermissions( + ['RoleID' => $roleID], + null, + $limitToSuffix, + $junctionOptions + ); + $result = array_merge($result, $junctionPermissions); + + return $result; + } + + /** + * Get the permissions for one or more roles. + * + * @param int $roleID The role to get the permissions for. + * @return array Returns a permission array suitable for use in a session. + */ + public function getPermissionsByRole($roleID) { + $inc = Gdn::userModel()->getPermissionsIncrement(); + $key = "perms:$inc:role:$roleID"; + + $permissions = Gdn::cache()->get($key); + if ($permissions === Gdn_Cache::CACHEOP_FAILURE) { + $sql = clone $this->SQL; + $sql->reset(); + + // Select all of the permission columns. + $permissionColumns = $this->permissionColumns(); + foreach ($permissionColumns as $columnName => $value) { + $sql->select('p.`'.$columnName.'`', 'MAX'); + } + + $sql->from('Permission p') + ->where('p.RoleID', $roleID) + ->select(['p.JunctionTable', 'p.JunctionColumn', 'p.JunctionID']) + ->groupBy(['p.JunctionTable', 'p.JunctionColumn', 'p.JunctionID']); + + $permissions = $sql->get()->resultArray(); + $permissions = UserModel::compilePermissions($permissions); + Gdn::cache()->store($key, $permissions); + } + + return $permissions; + } + + /** + * + * @param int $roleID + * @param string $limitToSuffix + * @param bool $includeJunction + * @param array|bool $overrides Form values used override current permission flags. + * @return array + */ + public function getPermissionsEdit($roleID, $limitToSuffix = '', $includeJunction = true, $overrides = false) { + $permissions = $this->getPermissions($roleID, $limitToSuffix, $includeJunction); + $permissions = $this->unpivotPermissions($permissions); + + if (is_array($overrides)) { + foreach ($permissions as $namespace) { + foreach ($namespace as $name => $currentPermission) { + if (stringBeginsWith('_', $name)) { + continue; + } + $postValue = val('PostValue', $currentPermission); + $currentPermission['Value'] = (int)in_array($postValue, $overrides); + } + } + } + + return $permissions; + } + + /** + * Get all of the global permissions for one or more roles. + * + * @param int|array $roleID The role(s) to get the permissions for. + * @param string $limitToSuffix Whether or not to limit the permissions to a suffix. + * @return array + */ + public function getGlobalPermissions($roleID, $limitToSuffix = '') { + $roleIDs = (array)$roleID; + + // Get the global permissions. + $data = $this->SQL + ->select('*') + ->from('Permission p') + ->whereIn('p.RoleID', array_merge($roleIDs, [0])) + ->where('p.JunctionTable is null') + ->orderBy('p.RoleID') + ->get()->resultArray(); + + $this->_MergeDisabledPermissions($data); + $data = Gdn_DataSet::index($data, 'RoleID'); + + $defaultRow = $data[0]; + unset($data[0], $defaultRow['RoleID'], $defaultRow['JunctionTable'], $defaultRow['JunctionColumn'], $defaultRow['JunctionID']); + $defaultRow = $this->stripPermissions($defaultRow, $defaultRow, $limitToSuffix); + if ($roleID) { + // When editing a role make sure the default permissions are false so as not to be misleading. + $defaultRow = array_fill_keys(array_keys($defaultRow), 0); + } + + foreach ($roleIDs as $iD) { + if (isset($data[$iD])) { + $data[$iD] = array_intersect_key($data[$iD], $defaultRow); + } else { + $data[$iD] = $defaultRow; + $data[$iD]['PermissionID'] = null; + } + } + + if (count($roleIDs) === 1) { + return array_pop($data); + } else { + return $data; + } + } + + /** + * Take a permission row and strip the global/local permissions from it. + * + * @param $row + * @param $defaultRow + * @param string $limitToSuffix + * @return mixed + */ + public function stripPermissions($row, $defaultRow, $limitToSuffix = '') { + $namespaces = $this->getNamespaces(); + + foreach ($defaultRow as $permissionName => $value) { + if (in_array($permissionName, ['PermissionID', 'RoleID', 'JunctionTable', 'JunctionColumn', 'JunctionID'])) { + continue; + } + + if (!$this->isGlobalPermission($value, $permissionName, $limitToSuffix, $namespaces)) { + unset($row[$permissionName]); + continue; + } + + switch ($defaultRow[$permissionName]) { + case 3: + $row[$permissionName] = 1; + break; + case 2: + $row[$permissionName] = 0; + break; + } + } + return $row; + } + + /** + * Returns whether or not a permission is a global permission. + * + * @param $value + * @param $permissionName + * @param $limitToSuffix + * @param $namespaces + * @return bool + */ + protected function isGlobalPermission($value, $permissionName, $limitToSuffix, $namespaces) { + if (!($value & 2)) { + return false; + } + if (!empty($limitToSuffix) && substr($permissionName, -strlen($limitToSuffix)) != $limitToSuffix) { + return false; + } + if ($index = strpos($permissionName, '.')) { + if (!in_array(substr($permissionName, 0, $index), $namespaces) && !in_array(substr($permissionName, 0, strrpos($permissionName, '.')), $namespaces)) { + return false; + } + } + return true; + } + + /** Merge junction permissions with global permissions if they are disabled. + * + * @param array $globalPermissions + * @return void + */ + protected function _MergeDisabledPermissions(&$globalPermissions) { + // Get the default permissions for junctions that are disabled. + $disabledPermissions = c('Garden.Permissions.Disabled'); + if (!$disabledPermissions) { + return; + } + + $disabledIn = []; + foreach ($disabledPermissions as $junctionTable => $disabled) { + if ($disabled) { + $disabledIn[] = $junctionTable; + } + } + + if (!$disabledIn) { + return; + } + + $disabledData = $this->SQL + ->select('*') + ->from('Permission p') + ->where('p.RoleID', 0) + ->whereIn('p.JunctionTable', $disabledIn) + ->get()->resultArray(); + + $defaultRow =& $globalPermissions[0]; + + // Loop through each row and add it's default definition to the global permissions. + foreach ($disabledData as $permissionRow) { + foreach ($permissionRow as $columnName => $value) { + if (in_array($columnName, ['PermissionID', 'RoleID', 'JunctionTable', 'JunctionColumn', 'JunctionID'])) { + continue; + } + + if ($value & 2) { + $setting = $value | val($columnName, $defaultRow, 0); + setValue($columnName, $defaultRow, $setting); + } + } + } + } + + /** + * Get all of the permission columns in the system. + * + * @param bool $junctionTable + * @param bool $junctionColumn + * @return mixed + * @throws Exception + */ + public function permissionColumns($junctionTable = false, $junctionColumn = false) { + $key = "{$junctionTable}__{$junctionColumn}"; + + if (!isset($this->_PermissionColumns[$key])) { + $sQL = clone $this->SQL; + $sQL->reset(); + + $sQL + ->select('*') + ->from('Permission') + ->limit(1); + + if ($junctionTable !== false && $junctionColumn !== false) { + $sQL + ->where('JunctionTable', $junctionTable) + ->where('JunctionColumn', $junctionColumn) + ->where('RoleID', 0); + } + + $cols = $sQL->get()->firstRow(DATASET_TYPE_ARRAY); + + unset($cols['RoleID'], $cols['JunctionTable'], $cols['JunctionColumn'], $cols['JunctionID']); + + $this->_PermissionColumns[$key] = $cols; + } + return $this->_PermissionColumns[$key]; + } + + /** + * + * + * @param $permissionName + * @return string + */ + public static function permissionNamespace($permissionName) { + if ($index = strpos($permissionName)) { + return substr($permissionName, 0, $index); + } + return ''; + } + + /** + * + * + * @param $data + * @param null $overrides + * @return array + */ + public function pivotPermissions($data, $overrides = null) { + // Get all of the columns in the permissions table. + $schema = $this->SQL->get('Permission', '', '', 1)->firstRow(DATASET_TYPE_ARRAY); + foreach ($schema as $key => $value) { + if (strpos($key, '.') !== false) { + $schema[$key] = 0; + } + } + unset($schema['PermissionID']); + $schema['RoleID'] = 0; + $schema['JunctionTable'] = null; + $schema['JunctionColumn'] = null; + $schema['JunctionID'] = null; + + $result = []; + if (is_array($data)) { + foreach ($data as $setPermission) { + // Get the parts out of the permission. + $parts = explode('//', $setPermission); + if (count($parts) > 1) { + // This is a junction permission. + $permissionName = $parts[1]; + $key = $parts[0]; + $parts = explode('/', $key); + $junctionTable = $parts[0]; + $junctionColumn = $parts[1]; + $junctionID = val('JunctionID', $overrides, $parts[2]); + if (count($parts) >= 4) { + $roleID = $parts[3]; + } else { + $roleID = val('RoleID', $overrides, null); + } + } else { + // This is a global permission. + $permissionName = $parts[0]; + $key = 'Global'; + $junctionTable = null; + $junctionColumn = null; + $junctionID = null; + $roleID = val('RoleID', $overrides, null); + } + + // Check for a row in the result for these permissions. + if (!array_key_exists($key, $result)) { + $newRow = $schema; + $newRow['RoleID'] = $roleID; + $newRow['JunctionTable'] = $junctionTable; + $newRow['JunctionColumn'] = $junctionColumn; + $newRow['JunctionID'] = $junctionID; + $result[$key] = $newRow; + } + $result[$key][$permissionName] = 1; + } + } + + return $result; + } + + /** + * Returns all rows from the specified JunctionTable/Column combination. This + * method assumes that $JuntionTable has a "Name" column. + * + * @param string $JunctionTable The name of the table from which to retrieve data. + * @param string $JunctionColumn The name of the column that represents the JunctionID in $JunctionTable. + * @return DataSet + */ + /*public function getJunctionData($JunctionTable, $JunctionColumn) { + return $this->SQL + ->select($JunctionColumn, '', 'JunctionID') + ->select('Name') + ->from($JunctionTable) + ->orderBy('Name', 'asc') + ->get(); + }*/ + + /** + * Return a dataset of all available junction tables (as defined in + * Permission.JunctionTable). + * + * @return DataSet + */ + /* public function getJunctionTables() { + return $this->SQL + ->select('JunctionTable, JunctionColumn') + ->from('Permission') + ->where('JunctionTable is not null') + ->groupBy('JunctionTable, JunctionColumn') + ->get(); + }*/ + + /** + * Allows the insertion of new permissions. If the permission(s) already + * exist in the database, or is not formatted properly, it will be skipped. + * + * @param mixed $Permission The permission (or array of permissions) to be added. + * @param string $JunctionTable The junction table to relate the permission(s) to. + * @param string $JunctionColumn The junction column to relate the permission(s) to. + */ + /* public function insertNew($Permission, $JunctionTable = '', $JunctionColumn = '') { + if (!is_array($Permission)) + $Permission = array($Permission); + + $PermissionCount = count($Permission); + // Validate the permissions first + if (validatePermissionFormat($Permission)) { + // Now save them + $this->defineSchema(); + for ($i = 0; $i < $PermissionCount; ++$i) { + // Check to see if the permission already exists + $ResultSet = $this->getWhere(array('Name' => $Permission[$i])); + // If not, insert it now + if ($ResultSet->numRows() == 0) { + $Values = array(); + $Values['Name'] = $Permission[$i]; + if ($JunctionTable != '') { + $Values['JunctionTable'] = $JunctionTable; + $Values['JunctionColumn'] = $JunctionColumn; + } + $this->insert($Values); + } + } + } + }*/ + + /** + * Save a permission row. + * + * @param array $values The values you want to save. See the Permission table for possible columns. + * @param bool $saveGlobal Also save a junction permission to the global permissions. + */ + public function save($values, $saveGlobal = false) { + // Get the list of columns that are available for permissions. + $permissionColumns = Gdn::permissionModel()->defineSchema()->fields(); + if (isset($values['Role'])) { + $permissionColumns['Role'] = true; + } + $values = array_intersect_key($values, $permissionColumns); + + // Figure out how to find the existing permission. + if (array_key_exists('PermissionID', $values)) { + $where = ['PermissionID' => $values['PermissionID']]; + unset($values['PermissionID']); + + $this->SQL->update('Permission', $this->_Backtick($values), $where)->put(); + } else { + $where = []; + + if (array_key_exists('RoleID', $values)) { + $where['RoleID'] = $values['RoleID']; + unset($values['RoleID']); + } elseif (array_key_exists('Role', $values)) { + // Get the RoleID. + $roleID = $this->SQL->getWhere('Role', ['Name' => $values['Role']])->value('RoleID'); + if (!$roleID) { + return; + } + $where['RoleID'] = $roleID; + unset($values['Role']); + } else { + $where['RoleID'] = 0; // default role. + } + + if (array_key_exists('JunctionTable', $values)) { + $where['JunctionTable'] = $values['JunctionTable']; + + // If the junction table was given then so must the other values. + if (array_key_exists('JunctionColumn', $values)) { + $where['JunctionColumn'] = $values['JunctionColumn']; + } + $where['JunctionID'] = $values['JunctionID']; + } else { + $where['JunctionTable'] = null; // no junction table. + $where['JunctionColumn'] = null; + $where['JunctionID'] = null; + } + + unset($values['JunctionTable'], $values['JunctionColumn'], $values['JunctionID']); + + $this->SQL->replace('Permission', $this->_Backtick($values), $where, true); + + if ($saveGlobal && !is_null($where['JunctionTable'])) { + // Save these permissions with the global permissions. + $where['JunctionTable'] = null; // no junction table. + $where['JunctionColumn'] = null; + $where['JunctionID'] = null; + + $this->SQL->replace('Permission', $this->_Backtick($values), $where, true); + } + } + + $this->clearPermissions(); + } + + /** + * + * + * @param $permissions + * @param null $allWhere + */ + public function saveAll($permissions, $allWhere = null) { + // Load the permission data corresponding to the where so unset permissions get ovewritten. + if (is_array($allWhere)) { + $allPermissions = $this->SQL->getWhere('Permission', $allWhere)->resultArray(); + // Find the permissions that were loaded, but not saved. + foreach ($allPermissions as $i => $allRow) { + foreach ($permissions as $saveRow) { + if ($allRow['RoleID'] == $saveRow['RoleID'] + && $allRow['JunctionTable'] == $saveRow['JunctionTable'] + && $allRow['JunctionID'] == $saveRow['JunctionID'] + ) { + unset($allPermissions[$i]); // saving handled already. + break; + } + } + } + // Make all permission false that need to be saved here. + foreach ($allPermissions as &$allRow) { + foreach ($allRow as $name => $value) { + if (strpos($name, '.') !== false) { + $allRow[$name] = 0; + } + } + } + if (count($allPermissions) > 0) { + $permissions = array_merge($permissions, $allPermissions); + } + } + + foreach ($permissions as $row) { + $this->save($row); + } + + // TODO: Clear the permissions for rows that aren't here. + } + + /** + * Reset permissions for all roles, based on the value in their Type column. + * + * @param string $type Role type to limit the updates to. + */ + public static function resetAllRoles($type = null) { + // Retrieve an array containing all available roles. + $roleModel = new RoleModel(); + if ($type) { + $result = $roleModel->getByType($type)->resultArray(); + $roles = array_column($result, 'Name', 'RoleID'); + } else { + $roles = $roleModel->getArray(); + } + + // Iterate through our roles and reset their permissions. + $permissions = Gdn::permissionModel(); + foreach ($roles as $roleID => $role) { + $permissions->resetRole($roleID); + } + } + + /** + * Reset permissions for a role, based on the value in its Type column. + * + * @param int $roleId ID of the role to reset permissions for. + * @throws Exception + */ + public function resetRole($roleId) { + // Grab the value of Type for this role. + $roleType = $this->SQL->getWhere('Role', ['RoleID' => $roleId])->value('Type'); + + if ($roleType == '') { + $roleType = RoleModel::TYPE_MEMBER; + } + + $defaults = $this->getDefaults(); + $rowDefaults = $this->getRowDefaults(); + + $resetValues = array_fill_keys(array_keys($rowDefaults), 0); + + if (array_key_exists($roleType, $defaults)) { + foreach ($defaults[$roleType] as $specificity => $permissions) { + $permissions['RoleID'] = $roleId; + $permissions = array_merge($resetValues, $permissions); + + if (strpos($specificity, ':')) { + list($junction, $junctionId) = explode(':', $specificity); + if ($junction && $junctionId) { + switch ($junction) { + case 'Category': + default: + $permissions['JunctionTable'] = $junction; + $permissions['JunctionColumn'] = 'PermissionCategoryID'; + $permissions['JunctionID'] = $junctionId; + } + } + } + + $this->save($permissions); + } + } + } + + /** + * Split a permission name into its constituant parts. + * + * @param string $permissionName The name of the permission. + * @return array The split permission in the form array(Namespace, Permission,Suffix). + */ + public static function splitPermission($permissionName) { + $i = strpos($permissionName, '.'); + $j = strrpos($permissionName, '.'); + + if ($i !== false) { // $j must also not be false + return [substr($permissionName, 0, $i), substr($permissionName, $i + 1, $j - $i - 1), substr($permissionName, $j + 1)]; + } else { + return [$permissionName, '', '']; + } + } + + /** + * Joins the query to a permission junction table and limits the results accordingly. + * + * @param Gdn_SQLDriver $sQL The SQL driver to add the permission to. + * @param mixed $permissions The permission name (or array of names) to use when limiting the query. + * @param string $foreignAlias The alias of the table to join to (ie. Category). + * @param string $foreignColumn The primary key column name of $junctionTable (ie. CategoryID). + * @param string $junctionTable + * @param string $junctionColumn + */ + public function sQLPermission($sQL, $permissions, $foreignAlias, $foreignColumn, $junctionTable = '', $junctionColumn = '') { + $session = Gdn::session(); + + // Figure out the junction table if necessary. + if (!$junctionTable && stringEndsWith($foreignColumn, 'ID')) { + $junctionTable = substr($foreignColumn, 0, -2); + } + + // Check to see if the permission is disabled. + if (c('Garden.Permission.Disabled.'.$junctionTable)) { + if (!$session->checkPermission($permissions)) { + $sQL->where('1', '0', false, false); + } + } elseif ($session->UserID <= 0 || (is_object($session->User) && $session->User->Admin != '1')) { + $sQL->distinct() + ->join('Permission _p', '_p.JunctionID = '.$foreignAlias.'.'.$foreignColumn, 'inner') + ->join('UserRole _ur', '_p.RoleID = _ur.RoleID', 'inner') + ->beginWhereGroup() + ->where('_ur.UserID', $session->UserID); + + if (!is_array($permissions)) { + $permissions = [$permissions]; + } + + $sQL->beginWhereGroup(); + foreach ($permissions as $permission) { + $sQL->where('_p.`'.$permission.'`', 1); + } + $sQL->endWhereGroup(); + } else { + // Force this method to play nice in case it is used in an or clause + // (ie. it returns true in a sql sense by doing 1 = 1) + $sQL->where('1', '1', false, false); + } + + return $sQL; + } + + /** + * + * + * @param $permissions + * @param bool $includeRole + * @return array + */ + public function unpivotPermissions($permissions, $includeRole = false) { + $result = []; + foreach ($permissions as $row) { + $this->_UnpivotPermissionsRow($row, $result, $includeRole); + } + return $result; + } + + /** + * + * + * @param $names + */ + public function undefine($names) { + $names = (array)$names; + $st = $this->Database->structure(); + $st->table('Permission'); + + foreach ($names as $name) { + if ($st->columnExists($name)) { + $st->dropColumn($name); + } + } + $st->reset(); + } + + /** + * + * + * @param $row + * @param $result + * @param bool $includeRole + */ + protected function _UnpivotPermissionsRow($row, &$result, $includeRole = false) { + $globalName = val('Name', $row); + + // Loop through each permission in the row and place them in the correct place in the grid. + foreach ($row as $permissionName => $value) { + list($namespace, $name, $suffix) = self::splitPermission($permissionName); + if (empty($name)) { + continue; // was some other column + } + if ($globalName) { + $namespace = $globalName; + } + + if (array_key_exists('JunctionTable', $row) && ($junctionTable = $row['JunctionTable'])) { + $key = "$junctionTable/{$row['JunctionColumn']}/{$row['JunctionID']}".($includeRole ? '/'.$row['RoleID'] : ''); + } else { + $key = '_'.$namespace; + } + + + // Check to see if the namespace is in the result. + if (!array_key_exists($key, $result)) { + $result[$key] = ['_Columns' => [], '_Rows' => [], '_Info' => ['Name' => $namespace]]; + } + $namespaceArray = &$result[$key]; + + // Add the names to the columns and rows. + $namespaceArray['_Columns'][$suffix] = true; + $namespaceArray['_Rows'][$name] = true; + + // Augment the value depending on the junction ID. + if (substr($key, 0, 1) === '_') { + $postValue = $permissionName; + } else { + $postValue = $key.'//'.$permissionName; + } + + $namespaceArray[$name.'.'.$suffix] = ['Value' => $value, 'PostValue' => $postValue]; + } + } + + /** + * Get the namespaces from enabled permissions. + * + * @return array Returns an array of permission prefixes. + */ + public function getNamespaces() { + if (!isset($this->namespaces)) { + $this->namespaces = $this->getAllowedPermissionNamespaces(); + } + $namespaces = $this->namespaces; + return $namespaces; + } + + +} diff --git a/vanilla/applications/dashboard/models/class.rolemodel.php b/vanilla/applications/dashboard/models/class.rolemodel.php new file mode 100644 index 0000000..7908c2b --- /dev/null +++ b/vanilla/applications/dashboard/models/class.rolemodel.php @@ -0,0 +1,883 @@ + [ + // all permissions + ], + 'Connect Manager' => [], + 'Connect Account Manager' => [], + 'Connect Copilot' => [ + 'Name' => ROLE_TOPCODER_CONNECT_COPILOT, + 'Type' => ROLE_TYPE_TOPCODER, + 'Garden.Uploads.Add' => 1 + ], + 'Connect Admin' => [ + // all permissions + ], + 'Connect Copilot Manager' => [], + 'Business Development Representative' => [], + 'Presales' => [], + 'Account Executive' => [], + 'Program Manager' => [], + 'Solution Architect'=> [], + 'Project Manager'=> [], + 'Topcoder User' => [] + ]; + + const TOPCODER_PROJECT_ROLES = [ + 'manager' => [], + 'copilot' => [ + 'Name' => ROLE_TOPCODER_PROJECT_COPILOT, + 'Type' => ROLE_TYPE_TOPCODER, + 'Garden.Uploads.Add' => 1 + ], + 'customer' => [], + 'observer'=> [], + 'account_manager'=> [], + 'program_manager'=> [], + 'account_executive'=> [], + 'solution_architect'=> [], + 'project_manager' => [] + ]; + + /** @var array|null All roles. */ + public static $Roles = null; + + /** @var array A list of permissions that define an increasing ranking of permissions. */ + public $RankPermissions = [ + 'Garden.Moderation.Manage', + 'Garden.Community.Manage', + 'Garden.Users.Add', + 'Garden.Settings.Manage', + 'Conversations.Moderation.Manage' + ]; + + /** + * Class constructor. Defines the related database table name. + */ + public function __construct() { + parent::__construct('Role'); + $this->fireEvent('Init'); + } + + /** + * Clear the roles cache. + */ + public function clearCache() { + $key = 'Roles'; + Gdn::cache()->remove($key); + } + + /** + * Define a role. + * + * @param $values + */ + public function define($values) { + if (array_key_exists('RoleID', $values)) { + $roleID = $values['RoleID']; + unset($values['RoleID']); + + $this->SQL->replace('Role', $values, ['RoleID' => $roleID], true); + } else { + // Check to see if there is a role with the same name. + $roleID = $this->SQL->getWhere('Role', ['Name' => $values['Name']])->value('RoleID', null); + + if (is_null($roleID)) { + // Figure out the next role ID. + $maxRoleID = $this->SQL->select('r.RoleID', 'MAX')->from('Role r')->get()->value('RoleID', 0); + $roleID = $maxRoleID + 1; + $values['RoleID'] = $roleID; + + // Insert the role. + $this->SQL->insert('Role', $values); + } else { + // Update the role. + $this->SQL->update('Role', $values, ['RoleID' => $roleID])->put(); + } + } + $this->clearCache(); + } + + /** + * Use with array_filter to remove PersonalInfo roles. + * + * @var mixed $roles Role name (string) or $role data (array or object). + * @return bool Whether role is NOT personal info (FALSE = remove it, it's personal). + */ + public static function filterPersonalInfo($role) { + if (is_string($role)) { + $roles = self::getByName($role); + $role = array_shift($roles); + } + + return (val('PersonalInfo', $role)) ? false : true; + } + + /** + * Returns a resultset of all roles. + * + * @inheritdoc + */ + public function get($orderFields = '', $orderDirection = 'asc', $limit = false, $pageNumber = false) { + return $this->SQL + ->select() + ->from('Role') + ->orderBy($orderFields ?: 'Sort', $orderDirection) + ->get(); + } + + /** + * Get the category specific permissions for a role. + * + * @param int $roleID The ID of the role to get the permissions for. + * @return array Returns an array of permissions. + */ + public function getCategoryPermissions($roleID) { + $permissions = Gdn::permissionModel()->getJunctionPermissions(['RoleID' => $roleID], 'Category'); + $result = []; + + foreach ($permissions as $perm) { + $row = ['CategoryID' => $perm['JunctionID']]; + unset($perm['Name'], $perm['JunctionID'], $perm['JunctionTable'], $perm['JunctionColumn']); + $row += $perm; + $result[] = $row; + } + + return $result; + } + + /** + * Get all of the roles including their ranking permissions. + * + * @return Gdn_DataSet Returns all of the roles with the ranking permissions. + */ + public function getWithRankPermissions() { + $this->SQL + ->select('r.*') + ->from('Role r') + ->leftJoin('Permission p', 'p.RoleID = r.RoleID and p.JunctionID is null') + ->orderBy('Sort', 'asc'); + + foreach ($this->RankPermissions as $permission) { + $this->SQL->select("`$permission`", '', $permission); + } + + $result = $this->SQL->get(); + return $result; + } + + /** + * Returns an array of RoleID => RoleName pairs. + * + * @return array + */ + public function getArray() { + $roleData = $this->get()->resultArray(); + $result = array_column($roleData, 'Name', 'RoleID'); + + return $result; + } + + /** + * Get the roles that the current user is allowed to assign to another user. + * + * @return array Returns an array in the format `[RoleID => 'Role Name']`. + */ + public function getAssignable() { + // Administrators can assign all roles. + if (Gdn::session()->checkPermission('Garden.Settings.Manage')) { + return $this->getArray(); + } + // Users that can't edit other users can't assign any roles. + if (!Gdn::session()->checkPermission('Garden.Users.Edit')) { + return []; + } + + $sql = Gdn::sql(); + + $sql->select('r.RoleID, r.Name') + ->from('Role r') + ->leftJoin('Permission p', 'p.RoleID = r.RoleID and p.JunctionID is null'); // join to global permissions + + // Community managers can assign permissions they have, + // but other users can't assign any ranking permissions. + $cM = Gdn::session()->checkPermission('Garden.Community.Manage'); + foreach ($this->RankPermissions as $permission) { + if (!$cM || !Gdn::session()->checkPermission($permission)) { + $sql->where("coalesce(`$permission`, 0)", '0', false, false); + } + } + + $roles = $sql->get()->resultArray(); + $roles = array_column($roles, 'Name', 'RoleID'); + + return $roles; + } + + /** + * Get the default role IDs for a type of role. + * + * @param string $type One of the {@link RoleModel::TYPE_*} constants. + * @return array Returns an array of role IDs. + */ + public static function getDefaultRoles($type) { + // Get the roles that match the type. + try { + $roleData = Gdn::sql()->select('RoleID')->getWhere('Role', ['Type' => $type])->resultArray(); + $roleIDs = array_column($roleData, 'RoleID'); + } catch (Exception $ex) { + // This exception happens when the type column hasn't been added to GDN_Role yet. + $roleIDs = []; + } + + // This method has to be backwards compatible with the old config roles. + switch ($type) { + case self::TYPE_APPLICANT: + $backRoleIDs = (array)c('Garden.Registration.ApplicantRoleID', null); + break; + case self::TYPE_GUEST: + $guestRoleData = Gdn::sql()->getWhere('UserRole', ['UserID' => 0])->resultArray(); + $backRoleIDs = array_column($guestRoleData, 'RoleID'); + break; + case self::TYPE_MEMBER: + $backRoleIDs = (array)c('Garden.Registration.DefaultRoles', null); + break; + case self::TYPE_UNCONFIRMED: + $backRoleIDs = (array)c('Garden.Registration.ConfirmEmailRole', null); + break; + default: + $backRoleIDs = []; + } + $roleIDs = array_merge($roleIDs, $backRoleIDs); + $roleIDs = array_unique($roleIDs); + + return $roleIDs; + } + + /** + * Get the default role IDs for all types of roles. + * + * @return array Returns an array of arrays indexed by role type. + */ + public static function getAllDefaultRoles() { + $result = array_fill_keys( + array_keys(self::getDefaultTypes(false)), + [] + ); + + // Add the roles per type from the role table. + $roleData = Gdn::sql()->getWhere('Role', ['Type is not null' => ''])->resultArray(); + foreach ($roleData as $row) { + $result[$row['Type']][] = $row['RoleID']; + } + + // Add the backwards compatible roles. + $result[self::TYPE_APPLICANT] = array_merge( + $result[self::TYPE_APPLICANT], + (array)c('Garden.Registration.ApplicantRoleID', null) + ); + + $guestRoleIDs = Gdn::sql()->getWhere('UserRole', ['UserID' => 0])->resultArray(); + $guestRoleIDs = array_column($guestRoleIDs, 'RoleID'); + $result[self::TYPE_GUEST] = array_merge( + $result[self::TYPE_GUEST], + $guestRoleIDs + ); + + $result[self::TYPE_MEMBER] = array_merge( + $result[self::TYPE_MEMBER], + (array)c('Garden.Registration.DefaultRoles', []) + ); + + $result[self::TYPE_UNCONFIRMED] = array_merge( + $result[self::TYPE_UNCONFIRMED], + (array)c('Garden.Registration.ConfirmEmailRole', null) + ); + + $result = array_map('array_unique', $result); + + return $result; + } + + /** + * Get an array of default role types. + * + * @param bool $translate Whether or not to translate the type names. + * @return array Returns an array in the form `[type => name]`. + */ + public static function getDefaultTypes($translate = true) { + $result = [ + self::TYPE_MEMBER => self::TYPE_MEMBER, + self::TYPE_GUEST => self::TYPE_GUEST, + self::TYPE_UNCONFIRMED => self::TYPE_UNCONFIRMED, + self::TYPE_APPLICANT => self::TYPE_APPLICANT, + self::TYPE_MODERATOR => self::TYPE_MODERATOR, + self::TYPE_ADMINISTRATOR => self::TYPE_ADMINISTRATOR, + self::TYPE_TOPCODER => self::TYPE_TOPCODER + ]; + if ($translate) { + $result = array_map('t', $result); + } + return $result; + } + + /** + * Returns a resultset of all roles that have editable permissions. + * + * public function getEditablePermissions() { + * return $this->SQL + * ->select() + * ->from('Role') + * ->where('EditablePermissions', '1') + * ->orderBy('Sort', 'asc') + * ->get(); + * } + */ + + /** + * Returns a resultset of role data related to the specified RoleID. + * + * @param int The RoleID to filter to. + */ + public function getByRoleID($roleID) { + return $this->getWhere(['RoleID' => $roleID])->firstRow(); + } + + /** + * Get the roles for a user. + * + * @param int $userID The user to get the roles for. + * @return Gdn_DataSet Returns the roles as a dataset (with array values). + * @see UserModel::getRoles() + */ + public function getByUserID($userID) { + $result = Gdn::userModel()->getRoles($userID); + return $result; + } + + /** + * Return all roles matching a specific type. + * + * @param string $type Type slug to match role records against. + * @return Gdn_DataSet + */ + public function getByType($type) { + return $this->SQL->select() + ->from('Role') + ->where('Type', $type) + ->get(); + } + + /** + * Returns a resultset of role data NOT related to the specified RoleID. + * + * @param int The RoleID to filter out. + */ + public function getByNotRoleID($roleID) { + return $this->getWhere(['RoleID <>' => $roleID]); + } + + /** + * Get the permissions for one or more roles. + * + * @param int|array $roleID One or more role IDs to get the permissions for. + * @return array Returns an array of permissions. + */ + public function getPermissions($roleID) { + $permissionModel = Gdn::permissionModel(); + $roleIDs = (array)$roleID; + + foreach ($roleIDs as $iD) { + $role = self::roles($iD); + $limitToSuffix = val('CanSession', $role, true) ? '' : 'View'; + } + + $result = $permissionModel->getPermissions($roleIDs, $limitToSuffix); + return $result; + } + + /** + * Returns the number of users assigned to the provided RoleID. If + * $usersOnlyWithThisRole is TRUE, it will return the number of users who + * are assigned to this RoleID and NO OTHER. + * + * @param int The RoleID to filter to. + * @param bool Indicating if the count should be any users with this RoleID, or users who are ONLY assigned to this RoleID. + */ + public function getUserCount($roleID, $usersOnlyWithThisRole = false) { + if ($usersOnlyWithThisRole) { + $data = $this->SQL->select('ur.UserID', 'count', 'UserCount') + ->from('UserRole ur') + ->join('UserRole urs', 'ur.UserID = urs.UserID') + ->groupBy('urs.UserID') + ->having('count(urs.RoleID) =', '1', false, false) + ->where('ur.RoleID', $roleID) + ->get() + ->firstRow(); + + return $data ? $data->UserCount : 0; + } else { + return $this->SQL->getCount('UserRole', ['RoleID' => $roleID]); + } + } + + /** + * Get the current number of applicants waiting to be approved. + * + * @param bool $force Whether or not to force a cache refresh. + * @return int Returns the number of applicants or 0 if the registration method isn't set to approval. + */ + public function getApplicantCount($force = false) { + if (c('Garden.Registration.Method') != 'Approval') { + return 0; + } + + $cacheKey = 'Moderation.ApplicantCount'; + + if ($force) { + Gdn::cache()->remove($cacheKey); + } + + $applicantRoleIDs = static::getDefaultRoles(self::TYPE_APPLICANT); + + $count = Gdn::cache()->get($cacheKey); + if ($count === Gdn_Cache::CACHEOP_FAILURE) { + $count = Gdn::sql() + ->select('u.UserID', 'count', 'UserCount') + ->from('User u') + ->join('UserRole ur', 'u.UserID = ur.UserID') + ->where('ur.RoleID', $applicantRoleIDs) + ->where('u.Deleted', '0') + ->get()->value('UserCount', 0); + + Gdn::cache()->store($cacheKey, $count, [ + Gdn_Cache::FEATURE_EXPIRY => 300 // 5 minutes + ]); + } + return $count; + } + + /** + * Retrieves all roles with the specified permission(s). + * + * @param mixed A permission (or array of permissions) to match. + */ + public function getByPermission($permission) { + if (!is_array($permission)) { + $permission = [$permission]; + } + + $this->SQL->select('r.*') + ->from('Role r') + ->join('Permission per', "per.RoleID = r.RoleID") + ->where('per.JunctionTable is null'); + + $this->SQL->beginWhereGroup(); + $permissionCount = count($permission); + for ($i = 0; $i < $permissionCount; ++$i) { + $this->SQL->where('per.'.$permission[$i], 1); + } + $this->SQL->endWhereGroup(); + return $this->SQL->get(); + } + + /** + * Get a role by name. + * + * @param array|string $names + */ + public static function getByName($names, &$missing = null) { + if (is_string($names)) { + $names = explode(',', $names); + $names = array_map('trim', $names); + } + + // Make a lookup array of the names. + $names = array_unique($names); + $names = array_combine($names, $names); + $names = array_change_key_case($names); + + $roles = RoleModel::roles(); + $result = []; + foreach ($roles as $roleID => $role) { + $name = strtolower($role['Name']); + + if (isset($names[$name])) { + $result[$roleID] = $role; + unset($names[$name]); + } + } + + $missing = array_values($names); + + return $result; + } + + /** + * + * + * @param null $roleID + * @param bool $force + * @return array|mixed|null|type + */ + public static function roles($roleID = null, $force = false) { + if (self::$Roles == null) { + $key = 'Roles'; + $roles = Gdn::cache()->get($key); + if ($roles === Gdn_Cache::CACHEOP_FAILURE) { + $roles = Gdn::sql()->get('Role', 'Sort')->resultArray(); + $roles = Gdn_DataSet::index($roles, ['RoleID']); + Gdn::cache()->store($key, $roles, [Gdn_Cache::FEATURE_EXPIRY => 24 * 3600]); + } + } else { + $roles = self::$Roles; + } + + if ($roleID === null) { + return $roles; + } elseif (array_key_exists($roleID, $roles)) + return $roles[$roleID]; + elseif ($force) + return ['RoleID' => $roleID, 'Name' => '']; + else { + return null; + } + } + + /** + * Save role data. + * + * @param array $formPostValues The role row to save. + * @param array|false $settings Additional settings for the save. + * @return bool|mixed Returns the role ID or false on error. + */ + public function save($formPostValues, $settings = false) { + // Define the primary key in this model's table. + $this->defineSchema(); + + $roleID = val('RoleID', $formPostValues); + $insert = $roleID > 0 ? false : true; + $doPermissions = val('DoPermissions', $settings, true); + + if ($insert) { + // Figure out the next role ID. + $maxRoleID = $this->SQL->select('r.RoleID', 'MAX')->from('Role r')->get()->value('RoleID', 0); + $roleID = $maxRoleID + 1; + + $this->addInsertFields($formPostValues); + $formPostValues['RoleID'] = strval($roleID); // string for validation + } else { + $this->addUpdateFields($formPostValues); + } + + // Validate the form posted values + if ($this->validate($formPostValues, $insert)) { + $fields = $this->Validation->schemaValidationFields(); + $fields = $this->coerceData($fields); + + if ($insert === false) { + $this->update($fields, ['RoleID' => $roleID]); + } else { + $this->insert($fields); + } + // Now update the role permissions + $role = $this->getByRoleID($roleID); + + if ($doPermissions) { + $permissionModel = Gdn::permissionModel(); + + if (array_key_exists('Permissions', $formPostValues)) { + $globalPermissions = $formPostValues['Permissions']; + $categoryPermissions = val('Category', $globalPermissions, []); + + // Massage the global permissions. + unset($globalPermissions['Category']); + $globalPermissions['RoleID'] = $roleID; + $globalPermissions['JunctionTable'] = null; + $globalPermissions['JunctionColumn'] = null; + $globalPermissions['JunctionID'] = null; + $permissions = [$globalPermissions]; + + // Massage the category permissions. + foreach ($categoryPermissions as $perm) { + $row = $perm; + $row['RoleID'] = $roleID; + $row['JunctionTable'] = 'Category'; + $row['JunctionColumn'] = 'PermissionCategoryID'; + $row['JunctionID'] = $row['CategoryID']; + unset($row['CategoryID']); + $permissions[] = $row; + } + } else { + $permissions = val('Permission', $formPostValues); + $permissions = $permissionModel->pivotPermissions($permissions, ['RoleID' => $roleID]); + } + + $permissionsWhere = ['RoleID' => $roleID]; + if (val('IgnoreCategoryPermissions', $formPostValues)) { + // Include the default category permissions when ignoring the rest. + $permissionsWhere['JunctionID'] = [null, -1]; + } + $permissionModel->saveAll($permissions, $permissionsWhere); + } + + if (Gdn::cache()->activeEnabled()) { + // Don't update the user table if we are just using cached permissions. + $this->clearCache(); + Gdn::userModel()->clearPermissions(); + } else { + // Remove the cached permissions for all users with this role. + $this->SQL->update('User') + ->join('UserRole', 'User.UserID = UserRole.UserID') + ->set('Permissions', '') + ->where(['UserRole.RoleID' => $roleID]) + ->put(); + } + } else { + $roleID = false; + } + return $roleID; + } + + /** + * + * + * @param $users + * @param string $userIDColumn + * @param string $rolesColumn + */ + public static function setUserRoles(&$users, $userIDColumn = 'UserID', $rolesColumn = 'Roles') { + $userIDs = array_unique(array_column($users, $userIDColumn)); + + // Try and get all of the mappings from the cache. + $keys = []; + foreach ($userIDs as $userID) { + $keys[$userID] = formatString(UserModel::USERROLES_KEY, ['UserID' => $userID]); + } + $userRoles = Gdn::cache()->get($keys); + if (!is_array($userRoles)) { + $userRoles = []; + } + + // Grab all of the data that doesn't exist from the DB. + $missingIDs = []; + foreach ($keys as $userID => $key) { + if (!array_key_exists($key, $userRoles)) { + $missingIDs[$userID] = $key; + } + } + if (count($missingIDs) > 0) { + $dbUserRoles = Gdn::sql() + ->select('ur.*') + ->from('UserRole ur') + ->whereIn('ur.UserID', array_keys($missingIDs)) + ->get()->resultArray(); + + $dbUserRoles = Gdn_DataSet::index($dbUserRoles, 'UserID', ['Unique' => false]); + + // Store the user role mappings. + foreach ($dbUserRoles as $userID => $rows) { + $roleIDs = array_column($rows, 'RoleID'); + $key = $keys[$userID]; + Gdn::cache()->store($key, $roleIDs); + $userRoles[$key] = $roleIDs; + } + } + + $allRoles = self::roles(); // roles indexed by role id. + + // Skip personal info roles + if (!checkPermission('Garden.PersonalInfo.View')) { + $allRoles = array_filter($allRoles, 'self::FilterPersonalInfo'); + } + + // Join the users. + foreach ($users as &$user) { + $userID = val($userIDColumn, $user); + $key = $keys[$userID]; + + $roleIDs = val($key, $userRoles, []); + $roles = []; + foreach ($roleIDs as $roleID) { + if (!array_key_exists($roleID, $allRoles)) { + continue; + } + $roles[$roleID] = $allRoles[$roleID]['Name']; + } + setValue($rolesColumn, $user, $roles); + } + } + + /** + * {@inheritdoc} + */ + public function delete($where = [], $options = []) { + if (is_numeric($where) || is_object($where)) { + deprecated('RoleModel->delete()', 'RoleModel->deleteandReplace()'); + + $result = $this->deleteAndReplace($where, $options); + return $result; + } + + throw new \BadMethodCallException("RoleModel->delete() is not supported.", 400); + } + + /** + * Delete a role. + * + * @param int $roleID The ID of the role to delete. + * @param array $options An array of options to affect the behavior of the delete. + * + * - **newRoleID**: The new role to point users to. + * @return bool Returns **true** on success or **false** otherwise. + */ + public function deleteID($roleID, $options = []) { + $result = $this->deleteAndReplace($roleID, val('newRoleID', $options)); + return $result; + } + + /** + * Delete a role. + * + * @param int $roleID The ID of the role to delete. + * @param int $newRoleID Assign users of the deleted role to this new role. + * @return bool Returns **true** on success or **false** on failure. + */ + public function deleteAndReplace($roleID, $newRoleID) { + // First update users that will be orphaned + if (is_numeric($newRoleID) && $newRoleID > 0) { + $this->SQL + ->options('Ignore', true) + ->update('UserRole') + ->join('UserRole urs', 'UserRole.UserID = urs.UserID') + ->groupBy('urs.UserID') + ->having('count(urs.RoleID) =', '1', false, false) + ->set('UserRole.RoleID', $newRoleID) + ->where(['UserRole.RoleID' => $roleID]) + ->put(); + } + + // Remove permissions for this role. + $permissionModel = Gdn::permissionModel(); + $permissionModel->delete($roleID); + + // Remove the role + $this->SQL->delete('UserRole', ['RoleID' => $roleID]); + $result = $this->SQL->delete('Role', ['RoleID' => $roleID]); + return $result; + } + + /** + * Get a list of a user's roles that are permitted to be seen. + * Optionally return all the role data or just one field name. + * + * @param $userID + * @param string $field optionally the field name from the role table to return. + * @return array|null|void + */ + public function getPublicUserRoles($userID, $field = "Name") { + if (!$userID) { + return; + } + + $unfilteredRoles = $this->getByUserID($userID)->resultArray(); + + // Hide personal info roles + $unformattedRoles = []; + if (!checkPermission('Garden.PersonalInfo.View')) { + $unformattedRoles = array_filter($unfilteredRoles, 'self::FilterPersonalInfo'); + } else { + $unformattedRoles = $unfilteredRoles; + } + + // If an empty string is passed as the field, return all the data from gdn_role row. + if (!$field) { + return $unformattedRoles; + } + + // If there is a return key, return an array with the field as the key + // and the value of the field as the value. + $formattedRoles = array_column($unformattedRoles, $field); + + return $formattedRoles; + } + + /** + * Enforce integrity between users and roles. + */ + public static function cleanUserRoles() { + $px = Gdn::database()->DatabasePrefix; + Gdn::sql()->query(" + delete ur + from {$px}UserRole as ur + left join {$px}Role as r on r.RoleID = ur.RoleID + left join {$px}User as u on u.UserID = ur.UserID + where r.RoleID is null + or u.UserID is null + "); + } + + /** + * @inheritdoc + */ + public function validate($values, $insert = false) { + $result = true; + $roleID = val('RoleID', $values); + + if ($roleID && !$insert) { + $role = $this->getID($roleID, DATASET_TYPE_ARRAY); + if ($role) { + $roleType = val('Type', $role); + $newType = val('Type', $values); + if (c('Garden.Registration.ConfirmEmail') && $roleType === self::TYPE_UNCONFIRMED && $newType !== self::TYPE_UNCONFIRMED) { + $totalUnconfirmedRoles = $this->getByType(self::TYPE_UNCONFIRMED)->count(); + if ($totalUnconfirmedRoles === 1) { + $this->Validation->addValidationResult('Type', 'One unconfirmed role is required for email confirmation.'); + } + } + } + } + + $result = $result && parent::validate($values, $insert); + return $result; + } +} diff --git a/vanilla/applications/dashboard/settings/class.hooks.php b/vanilla/applications/dashboard/settings/class.hooks.php new file mode 100644 index 0000000..9ae898d --- /dev/null +++ b/vanilla/applications/dashboard/settings/class.hooks.php @@ -0,0 +1,995 @@ +addonManager = $addonManager; + $this->mobileThemeKey = $config->get('Garden.MobileTheme'); + $this->desktopThemeKey = $config->get('Garden.Theme'); + $this->emoji = $emoji; + } + + /** + * Add emoji config to a controller's JavaScript definitions object. + * + * @param Gdn_Controller $controller + */ + private function addEmojiDefinitions(Gdn_Controller $controller) { + if ($this->emoji->isEnabled() === false) { + return; + } + + $controller->addDefinition("emoji", $this->emoji->getWebConfig()); + } + + /** + * Install the formatter to the container. + * + * @param Container $dic The container to initialize. + */ + public function container_init_handler(Container $dic) { + $dic->rule('HeadModule') + ->setShared(true) + ->addAlias('Head') + + ->rule('MenuModule') + ->setShared(true) + ->addAlias('Menu') + + ->rule('Gdn_Dispatcher') + ->addCall('passProperty', ['Menu', new Reference('MenuModule')]) + + ->rule(\Vanilla\Menu\CounterModel::class) + ->addCall('addProvider', [new Reference(ActivityCounterProvider::class)]) + ->addCall('addProvider', [new Reference(LogCounterProvider::class)]) + ->addCall('addProvider', [new Reference(RoleCounterProvider::class)]) + ; + } + + /** + * Fire before every page render. + * + * @param Gdn_Controller $sender + */ + public function base_render_before($sender) { + $session = Gdn::session(); + + + if ($sender->MasterView == 'admin') { + if (val('Form', $sender)) { + $sender->Form->setStyles('bootstrap'); + } + + $sender->CssClass = htmlspecialchars($sender->CssClass); + $sections = Gdn_Theme::section(null, 'get'); + if (is_array($sections)) { + foreach ($sections as $section) { + $sender->CssClass .= ' Section-'.$section; + } + } + + // Get our plugin nav items. + $navAdapter = new NestedCollectionAdapter(DashboardNavModule::getDashboardNav()); + $sender->EventArguments['SideMenu'] = $navAdapter; + $sender->fireEvent('GetAppSettingsMenuItems'); + + $sender->removeJsFile('jquery.popup.js'); + $sender->addJsFile('vendors/jquery.checkall.min.js', 'dashboard'); + $sender->addJsFile('buttongroup.js', 'dashboard'); + $sender->addJsFile('dashboard.js', 'dashboard'); + $sender->addJsFile('jquery.expander.js'); + $sender->addJsFile('settings.js', 'dashboard'); + $sender->addJsFile('vendors/tether.min.js', 'dashboard'); + $sender->addJsFile('vendors/bootstrap/util.js', 'dashboard'); + $sender->addJsFile('vendors/drop.min.js', 'dashboard'); + $sender->addJsFile('vendors/moment.min.js', 'dashboard'); + $sender->addJsFile('vendors/daterangepicker.js', 'dashboard'); + $sender->addJsFile('vendors/bootstrap/tooltip.js', 'dashboard'); + $sender->addJsFile('vendors/clipboard.min.js', 'dashboard'); + $sender->addJsFile('vendors/bootstrap/dropdown.js', 'dashboard'); + $sender->addJsFile('vendors/bootstrap/collapse.js', 'dashboard'); + $sender->addJsFile('vendors/bootstrap/modal.js', 'dashboard'); + $sender->addJsFile('vendors/icheck.min.js', 'dashboard'); + $sender->addJsFile('jquery.tablejenga.js', 'dashboard'); + $sender->addJsFile('vendors/prettify/prettify.js', 'dashboard'); + $sender->addJsFile('vendors/ace/ace.js', 'dashboard'); + $sender->addJsFile('vendors/ace/ext-searchbox.js', 'dashboard'); + $sender->addCssFile('vendors/tomorrow.css', 'dashboard'); + } + + // Check the statistics. + if ($sender->deliveryType() == DELIVERY_TYPE_ALL) { + Gdn::statistics()->check(); + } + + // Inform user of theme previewing + if ($session->isValid()) { + $previewThemeFolder = htmlspecialchars($session->getPreference('PreviewThemeFolder', '')); + $previewMobileThemeFolder = htmlspecialchars($session->getPreference('PreviewMobileThemeFolder', '')); + $previewThemeName = htmlspecialchars($session->getPreference( + 'PreviewThemeName', + $previewThemeFolder + )); + $previewMobileThemeName = htmlspecialchars($session->getPreference( + 'PreviewMobileThemeName', + $previewMobileThemeFolder + )); + + if ($previewThemeFolder != '') { + $sender->informMessage( + sprintf(t('You are previewing the %s desktop theme.'), wrap($previewThemeName, 'em')) + .'
' + .anchor(t('Apply'), 'settings/themes/'.$previewThemeFolder.'/'.$session->transientKey(), 'PreviewThemeButton') + .' '.anchor(t('Cancel'), 'settings/cancelpreview/'.$previewThemeFolder.'/'.$session->transientKey(), 'PreviewThemeButton') + .'
', + 'DoNotDismiss' + ); + } + + if ($previewMobileThemeFolder != '') { + $sender->informMessage( + sprintf(t('You are previewing the %s mobile theme.'), wrap($previewMobileThemeName, 'em')) + .'
' + .anchor(t('Apply'), 'settings/mobilethemes/'.$previewMobileThemeFolder.'/'.$session->transientKey(), 'PreviewThemeButton') + .' '.anchor(t('Cancel'), 'settings/cancelpreview/'.$previewMobileThemeFolder.'/'.$session->transientKey(), 'PreviewThemeButton') + .'
', + 'DoNotDismiss' + ); + } + } + + + if ($session->isValid()) { + $confirmed = val('Confirmed', Gdn::session()->User, true); + if (UserModel::requireConfirmEmail() && !$confirmed) { + $message = formatString(t('You need to confirm your email address.', 'You need to confirm your email address. Click here to resend the confirmation email.')); + $sender->informMessage($message, ''); + } + } + + // Add Message Modules (if necessary) + $messageCache = Gdn::config('Garden.Messages.Cache', []); + $location = $sender->Application.'/'.substr($sender->ControllerName, 0, -10).'/'.$sender->RequestMethod; + $exceptions = ['[Base]']; + + if (in_array($sender->MasterView, ['', 'default'])) { + $exceptions[] = '[NonAdmin]'; + } + + // SignIn popup is a special case + $signInOnly = ($sender->deliveryType() == DELIVERY_TYPE_VIEW && $location == 'Dashboard/entry/signin'); + if ($signInOnly) { + $exceptions = []; + } + + if ($sender->MasterView != 'admin' && !$sender->data('_NoMessages') && (val('MessagesLoaded', $sender) != '1' && $sender->MasterView != 'empty' && arrayInArray($exceptions, $messageCache, false) || inArrayI($location, $messageCache))) { + $messageModel = new MessageModel(); + $messageData = $messageModel->getMessagesForLocation($location, $exceptions, $sender->data('Category.CategoryID')); + foreach ($messageData as $message) { + $messageModule = new MessageModule($sender, $message); + if ($signInOnly) { // Insert special messages even in SignIn popup + echo $messageModule; + } elseif ($sender->deliveryType() == DELIVERY_TYPE_ALL) + $sender->addModule($messageModule); + } + $sender->MessagesLoaded = '1'; // Fixes a bug where render gets called more than once and messages are loaded/displayed redundantly. + } + + if ($sender->deliveryType() == DELIVERY_TYPE_ALL) { + $gdn_Statistics = Gdn::factory('Statistics'); + $gdn_Statistics->check($sender); + } + + // Allow forum embedding + if ($embed = c('Garden.Embed.Allow')) { + // Record the remote url where the forum is being embedded. + $remoteUrl = c('Garden.Embed.RemoteUrl'); + if ($remoteUrl) { + $sender->addDefinition('RemoteUrl', $remoteUrl); + } + if ($remoteUrlFormat = c('Garden.Embed.RemoteUrlFormat')) { + $sender->addDefinition('RemoteUrlFormat', $remoteUrlFormat); + } + + // Force embedding? + if (!isSearchEngine() && strtolower($sender->ControllerName) != 'entry') { + if (isMobile()) { + $forceEmbedForum = c('Garden.Embed.ForceMobile') ? '1' : '0'; + } else { + $forceEmbedForum = c('Garden.Embed.ForceForum') ? '1' : '0'; + } + + $sender->addDefinition('ForceEmbedForum', $forceEmbedForum); + $sender->addDefinition('ForceEmbedDashboard', c('Garden.Embed.ForceDashboard') ? '1' : '0'); + } + + $sender->addDefinition('Path', Gdn::request()->path()); + + $get = Gdn::request()->get(); + unset($get['p']); // kludge for old index.php?p=/path + $sender->addDefinition('Query', http_build_query($get)); + // $Sender->addDefinition('MasterView', $Sender->MasterView); + $sender->addDefinition('InDashboard', $sender->MasterView == 'admin' ? '1' : '0'); + + if ($embed === 2) { + $sender->addJsFile('vanilla.embed.local.js'); + } else { + $sender->addJsFile('embed_local.js'); + } + } else { + $sender->setHeader('X-Frame-Options', 'SAMEORIGIN'); + } + + + // Allow return to mobile site + $forceNoMobile = val('X-UA-Device-Force', $_COOKIE); + if ($forceNoMobile === 'desktop') { + $sender->addAsset('Foot', wrap(anchor(t('Back to Mobile Site'), '/profile/nomobile/1', 'js-hijack'), 'div'), 'MobileLink'); + } + + // Allow global translation of TagHint + if (c('Tagging.Discussions.Enabled')) { + $sender->addDefinition('TaggingAdd', Gdn::session()->checkPermission('Vanilla.Tagging.Add')); + $sender->addDefinition('TaggingSearchUrl', Gdn::request()->url('tags/search')); + $sender->addDefinition('MaxTagsAllowed', c('Vanilla.Tagging.Max', 5)); + $sender->addDefinition('TagHint', t('TagHint', 'Start to type...')); + } + + // Add symbols. + if ($sender->deliveryMethod() === DELIVERY_METHOD_XHTML) { + $sender->addAsset('Symbols', $sender->fetchView('symbols', '', 'Dashboard')); + } + + // Add emoji. + $this->addEmojiDefinitions($sender); + } + + /** + * Checks if the user is previewing a theme and, if so, updates the default master view. + * + * @param Gdn_Controller $sender + */ + public function base_beforeFetchMaster_handler($sender) { + $session = Gdn::session(); + if (!$session->isValid()) { + return; + } + if (isMobile()) { + $theme = htmlspecialchars($session->getPreference('PreviewMobileThemeFolder', '')); + } else { + $theme = htmlspecialchars($session->getPreference('PreviewThemeFolder', '')); + } + $isDefaultMaster = $sender->MasterView == 'default' || $sender->MasterView == ''; + if ($theme != '' && $isDefaultMaster) { + $htmlFile = paths(PATH_THEMES, $theme, 'views', 'default.master.tpl'); + if (file_exists($htmlFile)) { + $sender->EventArguments['MasterViewPath'] = $htmlFile; + } else { + // for default theme + $sender->EventArguments['MasterViewPath'] = $sender->fetchViewLocation('default.master', '', 'dashboard'); + } + } + } + + /** + * Setup dashboard navigation. + * + * @param $sender + */ + public function dashboardNavModule_init_handler($sender) { + /** @var DashboardNavModule $nav */ + $nav = $sender; + + $session = Gdn::session(); + $desktopTheme = $this->addonManager->lookupTheme($this->desktopThemeKey); + $mobileTheme = $this->addonManager->lookupTheme($this->mobileThemeKey); + $isDistinctMobileTheme = $mobileTheme !== $desktopTheme; + $hasThemeOptions = count($desktopTheme->getInfoValue('options', [])) > 0; + $hasMobileThemeOptions = $isDistinctMobileTheme && count($mobileTheme->getInfoValue('options', [])) > 0; + + $sort = -1; // Ensure these nav items come before any plugin nav items. + + $nav->addGroupToSection('Moderation', t('Site'), 'site') + ->addLinkToSectionIf('Garden.Community.Manage', 'Moderation', t('Messages'), '/dashboard/message', 'site.messages', '', $sort) + ->addLinkToSectionIf($session->checkPermission(['Garden.Users.Add', 'Garden.Users.Edit', 'Garden.Users.Delete'], false), 'Moderation', t('Users'), '/dashboard/user', 'site.users', '', $sort) + ->addLinkToSectionIf($session->checkPermission('Garden.Users.Approve') && (c('Garden.Registration.Method') == 'Approval'), 'Moderation', t('Applicants'), '/dashboard/user/applicants', 'site.applicants', '', $sort, ['popinRel' => '/dashboard/user/applicantcount'], false) + ->addLinkToSectionIf('Garden.Settings.Manage', 'Moderation', t('Ban Rules'), '/dashboard/settings/bans', 'site.bans', '', $sort) + + ->addGroupToSection('Moderation', t('Content'), 'moderation') + ->addLinkToSectionIf($session->checkPermission(['Garden.Moderation.Manage', 'Moderation.Spam.Manage'], false), 'Moderation', t('Spam Queue'), '/dashboard/log/spam', 'moderation.spam-queue', '', $sort) + ->addLinkToSectionIf($session->checkPermission(['Garden.Moderation.Manage', 'Moderation.ModerationQueue.Manage'], false), 'Moderation', t('Moderation Queue'), '/dashboard/log/moderation', 'moderation.moderation-queue', '', $sort, ['popinRel' => '/dashboard/log/count/moderate'], false) + ->addLinkToSectionIf($session->checkPermission(['Garden.Settings.Manage', 'Garden.Moderation.Manage'], false), 'Moderation', t('Change Log'), '/dashboard/log/edits', 'moderation.change-log', '', $sort) + + ->addGroup(t('Appearance'), 'appearance', '', -1) + ->addLinkIf($session->checkPermission(['Garden.Settings.Manage', 'Garden.Community.Manage'], false), t('Branding'), '/dashboard/settings/branding', 'appearance.banner', '', $sort) + ->addLinkIf('Garden.Settings.Manage', t('Layout'), '/dashboard/settings/layout', 'appearance.layout', '', $sort) + ->addLinkIf('Garden.Settings.Manage', t('Themes'), '/dashboard/settings/themes', 'appearance.themes', '', $sort) + ->addLinkIf($hasThemeOptions && $session->checkPermission('Garden.Settings.Manage'), t('Theme Options'), '/dashboard/settings/themeoptions', 'appearance.theme-options', '', $sort) + ->addLinkIf($hasMobileThemeOptions && $session->checkPermission('Garden.Settings.Manage'), t('Mobile Theme Options'), '/dashboard/settings/mobilethemeoptions', 'appearance.mobile-theme-options', '', $sort) + ->addLinkIf('Garden.Community.Manage', t('Avatars'), '/dashboard/settings/avatars', 'appearance.avatars', '', $sort) + ->addLinkIf('Garden.Settings.Manage', t('Email'), '/dashboard/settings/emailstyles', 'appearance.email', '', $sort) + + ->addGroup(t('Membership'), 'users', '', ['after' => 'appearance']) + ->addLinkIf($session->checkPermission(['Garden.Settings.Manage', 'Garden.Roles.Manage'], false), t('Roles & Permissions'), '/dashboard/role', 'users.roles', '', $sort) + ->addLinkIf('Garden.Settings.Manage', t('Registration'), '/dashboard/settings/registration', 'users.registration', '', $sort) + + ->addGroup(t('Discussions'), 'forum', '', ['after' => 'users']) + ->addLinkIf('Garden.Settings.Manage', t('Tagging'), 'settings/tagging', 'forum.tagging', $sort) + + ->addGroup(t('Reputation'), 'reputation', '', ['after' => 'forum']) + + ->addGroup(t('Connections'), 'connect', '', ['after' => 'reputation']) + ->addLinkIf('Garden.Settings.Manage', t('Social Connect', 'Social Media'), '/social/manage', 'connect.social', '', $sort) + + ->addGroup(t('Addons'), 'add-ons', '', ['after' => 'connect']) + ->addLinkIf('Garden.Settings.Manage', t('Plugins'), '/dashboard/settings/plugins', 'add-ons.plugins', '', $sort) + ->addLinkIf('Garden.Settings.Manage', t('Applications'), '/dashboard/settings/applications', 'add-ons.applications', '', $sort) + + ->addGroup(t('Technical'), 'site-settings', '', ['after' => 'reputation']) + ->addLinkIf('Garden.Settings.Manage', t('Locales'), '/settings/locales', 'site-settings.locales', '', $sort) + ->addLinkIf('Garden.Settings.Manage', t('Outgoing Email'), '/dashboard/settings/email', 'site-settings.email', '', $sort) + ->addLinkIf('Garden.Settings.Manage', t('Security'), '/dashboard/settings/security', 'site-settings.security', '', $sort) + ->addLinkIf('Garden.Settings.Manage', t('Routes'), '/dashboard/routes', 'site-settings.routes', '', $sort) + ->addLinkIf('Garden.Settings.Manage', t('Statistics'), '/dashboard/statistics', 'site-settings.statistics', '', $sort) + + ->addGroupIf('Garden.Settings.Manage', t('Forum Data'), 'forum-data', '', ['after' => 'site-settings']) + ->addLinkIf( + \Vanilla\FeatureFlagHelper::featureEnabled('Import') && $session->checkPermission('Garden.Import'), + t('Import'), + '/dashboard/import', + 'forum-data.import', + '', + $sort + ); + } + + /** + * Aggressively prompt users to upgrade PHP version. + * + * @param $sender + */ + public function settingsController_render_before($sender) { + // Set this in your config to dismiss our upgrade warnings. Not recommended. + if (c('Vanilla.WarnedMeToUpgrade') === 'PHP 7.0') { + return; + } + + $phpVersion = phpversion(); + if (version_compare($phpVersion, '7.1') < 0) { + $upgradeMessage = ['Content' => 'We recommend using at least PHP 7.1. Support for PHP '.htmlspecialchars($phpVersion).' may be dropped in upcoming releases.', 'AssetTarget' => 'Content', 'CssClass' => 'WarningMessage']; + $messageModule = new MessageModule($sender, $upgradeMessage); + $sender->addModule($messageModule); + } + + $mysqlVersion = gdn::sql()->version(); + if (version_compare($mysqlVersion, '5.6') < 0) { + $upgradeMessage = ['Content' => 'We recommend using at least MySQL 5.7 or MariaDB 10.2. Version '.htmlspecialchars($mysqlVersion).' will not support all upcoming Vanilla features.', 'AssetTarget' => 'Content', 'CssClass' => 'InfoMessage']; + $messageModule = new MessageModule($sender, $upgradeMessage); + $sender->addModule($messageModule); + } + } + + /** + * List all tags and allow searching + * + * @param SettingsController $sender + */ + public function settingsController_tagging_create($sender, $search = null, $type = null, $page = null) { + $sender->permission('Garden.Settings.Manage'); + + $sender->title('Tagging'); + $sender->setHighlightRoute('settings/tagging'); + $sQL = Gdn::sql(); + + /** @var Gdn_Form $form */ + $form = $sender->Form; + + if ($form->authenticatedPostBack()) { + $formValue = (bool)$form->getFormValue('Tagging.Discussions.Enabled'); + saveToConfig('Tagging.Discussions.Enabled', $formValue); + } + + // Get all tag types + $tagModel = TagModel::instance(); + $tagTypes = $tagModel->getTagTypes(); + + + list($offset, $limit) = offsetLimit($page, 100); + $sender->setData('_Limit', $limit); + + if ($search) { + $sQL->like('Name', $search, 'right'); + } + + $queryType = $type; + + if (strtolower($type) == 'all' || $search || $type === null) { + $queryType = false; + $type = ''; + } + + // This type doesn't actually exist, but it will represent the blank types in the column. + if (strtolower($type) == 'tags') { + $queryType = ''; + } + + if (!$search && ($queryType !== false)) { + $sQL->where('Type', $queryType); + } + + $tagTypes = array_change_key_case($tagTypes, CASE_LOWER); + + // Store type for view + $tagType = !empty($type) ? $type : 'All'; + $sender->setData('_TagType', $tagType); + + // Store tag types + $sender->setData('_TagTypes', $tagTypes); + + // Determine if new tags can be added for the current type. + $canAddTags = (!empty($tagTypes[$type]['addtag']) && $tagTypes[$type]['addtag']) ? 1 : 0; + $canAddTags &= checkPermission('Vanilla.Tagging.Add'); + + $sender->setData('_CanAddTags', $canAddTags); + + $data = $sQL + ->select('t.*') + ->from('Tag t') + ->orderBy('t.CountDiscussions', 'desc') + ->limit($limit, $offset) + ->get()->resultArray(); + + $sender->setData('Tags', $data); + + if ($search) { + $sQL->like('Name', $search, 'right'); + } + + // Make sure search uses its own search type, so results appear in their own tab. + $sender->Form->Action = url('/settings/tagging/?type='.$tagType); + + // Search results pagination will mess up a bit, so don't provide a type in the count. + $recordCountWhere = ['Type' => $queryType]; + if ($queryType === false) { + $recordCountWhere = []; + } + if ($search) { + $recordCountWhere = []; + } + + $sender->setData('RecordCount', $sQL->getCount('Tag', $recordCountWhere)); + $sender->render('tagging'); + } + + /** + * Add the tags endpoint to the settingsController + * + * @param SettingsController $sender + * @param string $action + * + */ + public function settingsController_tags_create($sender, $action) { + $sender->permission('Garden.Settings.Manage'); + + switch($action) { + case 'delete': + $tagID = val(1, $sender->RequestArgs); + $tagModel = new TagModel(); + $tag = $tagModel->getID($tagID, DATASET_TYPE_ARRAY); + + if ($sender->Form->authenticatedPostBack()) { + // Delete tag & tag relations. + $sQL = Gdn::sql(); + $sQL->delete('TagDiscussion', ['TagID' => $tagID]); + $sQL->delete('Tag', ['TagID' => $tagID]); + $tag['Name'] = htmlspecialchars($tag['Name']); + $tag['FullName'] = htmlspecialchars($tag['FullName']); + $sender->informMessage(formatString(t('{Name} deleted.'), $tag)); + $sender->jsonTarget("#Tag_{$tag['TagID']}", null, 'Remove'); + } + + $sender->render('blank', 'utility', 'dashboard'); + break; + case 'edit': + $sender->setHighlightRoute('settings/tagging'); + $sender->title(t('Edit Tag')); + $tagID = val(1, $sender->RequestArgs); + + // Set the model on the form. + $tagModel = new TagModel; + $sender->Form->setModel($tagModel); + $tag = $tagModel->getID($tagID); + $sender->Form->setData($tag); + + // Make sure the form knows which item we are editing. + $sender->Form->addHidden('TagID', $tagID); + + if ($sender->Form->authenticatedPostBack()) { + // Make sure the tag is valid + $tagData = $sender->Form->getFormValue('Name'); + if (!TagModel::validateTag($tagData)) { + $sender->Form->addError('@'.t('ValidateTag', 'Tags cannot contain commas.')); + } + + // Make sure that the tag name is not already in use. + if ($tagModel->getWhere(['TagID <>' => $tagID, 'Name' => $tagData])->numRows() > 0) { + $sender->setData('MergeTagVisible', true); + if (!$sender->Form->getFormValue('MergeTag')) { + $sender->Form->addError('The specified tag name is already in use.'); + } + } + + if ($sender->Form->save()) { + $sender->informMessage(t('Your changes have been saved.')); + $sender->setRedirectTo('/settings/tagging'); + } + } + + $sender->render('tags'); + break; + case 'add': + default: + $sender->setHighlightRoute('settings/tagging'); + $sender->title('Add Tag'); + + // Set the model on the form. + $tagModel = new TagModel; + $sender->Form->setModel($tagModel); + + // Add types if allowed to add tags for it, and not '' or 'tags', which + // are the same. + $tagType = Gdn::request()->get('type'); + if (strtolower($tagType) != 'tags' && $tagModel->canAddTagForType($tagType)) { + $sender->Form->addHidden('Type', $tagType, true); + } + + if ($sender->Form->authenticatedPostBack()) { + // Make sure the tag is valid + $tagName = $sender->Form->getFormValue('Name'); + if (!TagModel::validateTag($tagName)) { + $sender->Form->addError('@'.t('ValidateTag', 'Tags cannot contain commas.')); + } + + $tagType = $sender->Form->getFormValue('Type'); + if (!$tagModel->canAddTagForType($tagType)) { + $sender->Form->addError('@'.t('ValidateTagType', 'That type does not accept manually adding new tags.')); + } + + // Make sure that the tag name is not already in use. + if ($tagModel->getWhere(['Name' => $tagName])->numRows() > 0) { + $sender->Form->addError('The specified tag name is already in use.'); + } + + $saved = $sender->Form->save(); + if ($saved) { + $sender->informMessage(t('Your changes have been saved.')); + $sender->setRedirectTo('/settings/tagging'); + } + } + + $sender->render('tags'); + break; + } + } + + /** + * Add the tag endpoint to the discussionController + * + * @param DiscussionController $sender + * @param int $discussionID + * @throws Exception + * + */ + public function discussionController_tag_create($sender, $discussionID, $origin) { + if (!c('Tagging.Discussions.Enabled')) { + throw new Exception('Not found', 404); + } + + if (!filter_var($discussionID, FILTER_VALIDATE_INT)) { + throw notFoundException('Discussion'); + } + + $discussion = DiscussionModel::instance()->getID($discussionID, DATASET_TYPE_ARRAY); + if (!$discussion) { + throw notFoundException('Discussion'); + } + + $hasPermission = Gdn::session()->checkPermission('Garden.Moderation.Manage'); + if (!$hasPermission && $discussion['InsertUserID'] !== GDN::session()->UserID) { + throw permissionException('Garden.Moderation.Manage'); + } + $sender->title('Add Tags'); + + if ($sender->Form->authenticatedPostBack()) { + $rawFormTags = $sender->Form->getFormValue('Tags'); + $formTags = TagModel::splitTags($rawFormTags); + + if (!$formTags) { + $sender->Form->addError('@'.t('No tags provided.')); + } else { + // If we're associating with categories + $categoryID = -1; + if (c('Vanilla.Tagging.CategorySearch', false)) { + $categoryID = val('CategoryID', $discussion, -1); + } + + // Save the tags to the db. + TagModel::instance()->saveDiscussion($discussionID, $formTags, 'Tag', $categoryID); + + $sender->informMessage(t('The tags have been added to the discussion.')); + } + } + + $sender->render('tag', 'discussion', 'vanilla'); + } + + /** + * Set P3P header because IE won't allow cookies thru the iFrame without it. + * + * This must be done in the Dispatcher because of PrivateCommunity. + * That precludes using Controller->SetHeader. + * This is done so comment & forum embedding can work in old IE. + * + * @param Gdn_Dispatcher $sender + */ + public function gdn_dispatcher_appStartup_handler($sender) { + safeHeader('P3P: CP="CAO PSA OUR"', true); + + if ($sso = Gdn::request()->get('sso')) { + saveToConfig('Garden.Registration.SendConnectEmail', false, false); + + $deliveryMethod = $sender->getDeliveryMethod(Gdn::request()); + $isApi = $deliveryMethod === DELIVERY_METHOD_JSON; + + $userID = false; + try { + $currentUserID = Gdn::session()->UserID; + $userID = Gdn::userModel()->sso($sso); + } catch (Exception $ex) { + trace($ex, TRACE_ERROR); + } + + if ($userID) { + Gdn::session()->start($userID, !$isApi, !$isApi); + if ($isApi) { + Gdn::session()->validateTransientKey(true); + } + + if ($userID != $currentUserID) { + Gdn::userModel()->fireEvent('AfterSignIn'); + } + } else { + // There was some sort of error. Let's print that out. + foreach (Gdn::userModel()->Validation->resultsArray() as $msg) { + trace($msg, TRACE_ERROR); + } + Gdn::userModel()->Validation->reset(); + } + + // Let's redirect to the same url but without the sso parameter to be sure there will be + // no leak via the Referer field. + $deliveryType = $sender->getDeliveryType($deliveryMethod); + if (!$isApi && !Gdn::request()->isPostBack() && $deliveryType !== DELIVERY_TYPE_DATA) { + $url = trim(preg_replace('#(\?.*)sso=[^&]*&?(.*)$#', '$1$2', Gdn::request()->pathAndQuery()), '&'); + redirectTo($url); + } + } + } + + /** + * Check if we have a valid token associated with the request. + * The checkAccessToken was previously done in gdn_dispatcher_appStartup_handler hook. + * It was changed to have the access token auth happen as close as possible to standard auth. + * It's necessary to do it via events until Vanilla overhauls its authentication workflow. + */ + public function gdn_auth_startAuthenticator_handler() { + $this->checkAccessToken(); + } + + /** + * Check to see if a user is banned. + * + * @throws Exception if the user is banned. + */ + public function base_afterSignIn_handler() { + if (!Gdn::session()->isValid()) { + if ($ban = Gdn::session()->getPermissions()->getBan()) { + throw new ClientException($ban['msg'], 401, $ban); + } else { + if (!Gdn::session()->getPermissions()->has('Garden.SignIn.Allow')) { + throw new PermissionException('Garden.SignIn.Allow'); + } else { + throw new ClientException('The session could not be started', 401); + } + } + } + } + + /** + * Check the access token. + */ + private function checkAccessToken() { + if (!stringBeginsWith(Gdn::request()->getPath(), '/api/')) { + return; + } + + $hasAuthHeader = (!empty($_SERVER['HTTP_AUTHORIZATION']) && preg_match('`^Bearer\s+(v[a-z]\.[^\s]+)`i', $_SERVER['HTTP_AUTHORIZATION'], $m)); + $hasTokenParam = !empty($_GET['access_token']); + if (!$hasAuthHeader && !$hasTokenParam) { + return; + } + + $token = empty($_GET['access_token']) ? $m[1] : $_GET['access_token']; + if ($token) { + $model = new AccessTokenModel(); + + try { + $authRow = $model->verify($token, true); + + Gdn::session()->start($authRow['UserID'], false, false); + Gdn::session()->validateTransientKey(true); + } catch (\Exception $ex) { + // Add a psuedo-WWW-Authenticate header. We want the response to know, but don't want to kill everything. + $msg = $ex->getMessage(); + safeHeader("X-WWW-Authenticate: error=\"invalid_token\", error_description=\"$msg\""); + } + } + } + + /** + * @param Gdn_Dispatcher $sender + */ + public function gdn_dispatcher_sendHeaders_handler($sender) { + $csrfToken = Gdn::request()->post( + Gdn_Session::CSRF_NAME, + Gdn::request()->get( + Gdn_Session::CSRF_NAME, + Gdn::request()->getValueFrom(Gdn_Request::INPUT_SERVER, 'HTTP_X_CSRF_TOKEN') + ) + ); + + if ($csrfToken && Gdn::session()->isValid() && !Gdn::session()->validateTransientKey($csrfToken)) { + safeHeader('X-CSRF-Token: '.Gdn::session()->transientKey()); + } + } + + /** + * Method for plugins that want a friendly /sso method to hook into. + * + * @param RootController $sender + * @param string $target The url to redirect to after sso. + */ + public function rootController_sso_create($sender, $target = '') { + if (!$target) { + $target = $sender->Request->get('redirect'); + if (!$target) { + $target = '/'; + } + } + + // Get the default authentication provider. + $defaultProvider = Gdn_AuthenticationProviderModel::getDefault(); + $sender->EventArguments['Target'] = $target; + $sender->EventArguments['DefaultProvider'] = $defaultProvider; + $handled = false; + $sender->EventArguments['Handled'] =& $handled; + + $sender->fireEvent('SSO'); + + // If an event handler didn't handle the signin then just redirect to the target. + if (!$handled) { + redirectTo($target); + } + } + + /** + * Clear user navigation preferences if we can't find the explicit method on the controller. + * + * @param Gdn_Controller $sender + * @param array $args Event arguments. We can expect a 'PathArgs' key here. + */ + public function gdn_dispatcher_methodNotFound_handler($sender, $args) { + // If PathArgs is empty, the user hit the root, and we assume they want the index. + // If not, they got redirected to the root because their controller method was not + // found. We should clear the user prefs in that case. + if (!empty($args['PathArgs'])) { + if (Gdn::session()->isValid()) { + $uri = Gdn::request()->getRequestArguments('server')['REQUEST_URI']; + try { + $userModel = new UserModel(); + $userModel->clearSectionNavigationPreference($uri); + } catch (Exception $ex) { + // Nothing + } + } + } + } + + /** + * + * + * @param SiteNavModule $sender + */ + public function siteNavModule_init_handler($sender) { + + // GLOBALS + + // Add a link to the community home. + $sender->addLinkToGlobals(t('Community Home'), '/', 'main.home', '', -100, ['icon' => 'home'], false); + $sender->addGroupToGlobals('', 'etc', '', 100); + $sender->addLinkToGlobalsIf(Gdn::session()->isValid() && isMobile(), t('Full Site'), '/profile/nomobile', 'etc.nomobile', 'js-hijack', 100, ['icon' => 'resize-full']); + $sender->addLinkToGlobalsIf(Gdn::session()->isValid(), t('Sign Out'), signOutUrl(), 'etc.signout', '', 100, ['icon' => 'signout']); + $sender->addLinkToGlobalsIf(!Gdn::session()->isValid(), t('Sign In'), signinUrl(), 'etc.signin', '', 100, ['icon' => 'signin']); + + // DEFAULTS + + if (!Gdn::session()->isValid()) { + return; + } + + $sender->addLinkIf(Gdn::session()->isValid(), t('Profile'), '/profile', 'main.profile', 'profile', 10, ['icon' => 'user']) + ->addLinkIf('Garden.Activity.View', t('Activity'), '/activity', 'main.activity', 'activity', 10, ['icon' => 'time']); + + // Add the moderation items. + $sender->addGroup(t('Moderation'), 'moderation', 'moderation', 90); + if (Gdn::session()->checkPermission('Garden.Users.Approve')) { + $roleModel = new RoleModel(); + $applicant_count = (int)$roleModel->getApplicantCount(); + if ($applicant_count > 0 || true) { + $sender->addLink(t('Applicants'), '/user/applicants', 'moderation.applicants', 'applicants', [], ['icon' => 'user', 'badge' => $applicant_count]); + } + } + $sender->addLinkIf('Garden.Moderation.Manage', t('Spam Queue'), '/log/spam', 'moderation.spam', 'spam', [], ['icon' => 'spam']) + ->addLinkIf('Garden.Settings.Manage', t('Dashboard'), '/settings', 'etc.dashboard', 'dashboard', [], ['icon' => 'dashboard']); + + $user = Gdn::controller()->data('Profile'); + $user_id = val('UserID', $user); + + //EDIT PROFILE SECTION + + // Users can edit their own profiles and moderators can edit any profile. + $sender->addLinkToSectionIf(hasEditProfile($user_id), 'EditProfile', t('Profile'), userUrl($user, '', 'edit'), 'main.editprofile', '', [], ['icon' => 'edit']) + ->addLinkToSectionIf('Garden.Users.Edit', 'EditProfile', t('Edit Account'), '/user/edit/'.$user_id, 'main.editaccount', 'Popup', [], ['icon' => 'cog']) + ->addLinkToSection('EditProfile', t('Back to Profile'), userUrl($user), 'main.profile', '', 100, ['icon' => 'arrow-left']); + + + //PROFILE SECTION + + $sender->addLinkToSectionIf(c('Garden.Profile.ShowActivities', true), 'Profile', t('Activity'), userUrl($user, '', 'activity'), 'main.activity', '', [], ['icon' => 'time']) + ->addLinkToSectionIf(Gdn::controller()->data('Profile.UserID') == Gdn::session()->UserID, 'Profile', t('Notifications'), userUrl($user, '', 'notifications'), 'main.notifications', '', [], ['icon' => 'globe', 'badge' => Gdn::controller()->data('Profile.CountNotifications')]) + // Show the invitations if we're using the invite registration method. + ->addLinkToSectionIf(strcasecmp(c('Garden.Registration.Method'), 'invitation') === 0, 'Profile', t('Invitations'), userUrl($user, '', 'invitations'), 'main.invitations', '', [], ['icon' => 'ticket']) + // Users can edit their own profiles and moderators can edit any profile. + ->addLinkToSectionIf(hasEditProfile($user_id), 'Profile', t('Edit Profile'), userUrl($user, '', 'edit'), 'Profile', 'main.editprofile', '', [], ['icon' => 'edit']); + + } + + /** + * After executing /settings/utility/update check if any role permissions have been changed, if not reset all the permissions on the roles. + * + * @param $sender + */ + public function updateModel_afterStructure_handler($sender) { + // Only setup default permissions if no role permissions are set. + $hasPermissions = Gdn::sql()->getWhere('Permission', ['RoleID >' => 0])->firstRow(DATASET_TYPE_ARRAY); + if (!$hasPermissions) { + PermissionModel::resetAllRoles(); + + // TODO: refactor it + //create Topocder roles + + $RoleModel = new RoleModel(); + $PermissionModel = new PermissionModel(); + // Configure default permission for Topcoder roles + $allRoles = $RoleModel->getByType(RoleModel::TYPE_TOPCODER)->resultArray(); + foreach ($allRoles as $role) { + $allPermissions = $PermissionModel->getRolePermissions($role['RoleID']); + foreach ($allPermissions as $permission) { + $roleName = $role['Name']; + if (array_key_exists($roleName, RoleModel::TOPCODER_ROLES)) { + if ($roleName == RoleModel::ROLE_TOPCODER_CONNECT_ADMIN || $roleName == RoleModel::ROLE_TOPCODER_ADMINISTRATOR) { + foreach ($permission as $key => $value) { + if ($key != 'PermissionID' && $key != 'RoleID' && $key != 'JunctionTable' && $key != 'JunctionColumn' + && $key !== 'JunctionID') { + $permission[$key] = 1; + } + } + } else { + $globalRolePermissions = RoleModel::TOPCODER_ROLES[$roleName]; + foreach ($permission as $key => $value) { + if ($key != 'PermissionID' && $key != 'RoleID' && $key != 'JunctionTable' && $key != 'JunctionColumn' + && $key !== 'JunctionID') { + $permission[$key] = array_key_exists($key, $globalRolePermissions) ? $globalRolePermissions[$key] : $value; + } + } + } + $PermissionModel->save($permission); + } + } + } + // Configure default category permission for Topcoder roles + foreach ($allRoles as $role) { + $allPermissions = $PermissionModel->getRolePermissions($role['RoleID'], '', 'Category', 'PermissionCategoryID', -1); + foreach ($allPermissions as $permission) { + $roleName = $role['Name']; + if ($roleName == RoleModel::ROLE_TOPCODER_CONNECT_ADMIN || $roleName == RoleModel::ROLE_TOPCODER_ADMINISTRATOR) { + foreach ($permission as $key => $value) { + if ($key != 'PermissionID' && $key != 'RoleID' && $key != 'JunctionTable' && $key != 'JunctionColumn' + && $key !== 'JunctionID') { + $permission[$key] = 1; + } + $PermissionModel->save($permission); + } + } + } + } + } + } + + /** + * Copy a file locally so that it can be manipulated by php. + * + * @param Gdn_Upload $sender The upload object doing the manipulation. + * @param array $args Arguments useful for copying the file. + * @throws Exception Throws an exception if there was a problem copying the file for local use. + */ + public function gdn_upload_copyLocal_handler($sender, $args) { + $parsed = $args['Parsed']; + if ($parsed['Type'] !== 'static' || $parsed['Domain'] !== 'v') { + return; + } + // Sanitize $parsed['Name'] to prevent path traversal. + $parsed['Name'] = str_replace('..', '', $parsed['Name']); + $remotePath = PATH_ROOT.'/'.$parsed['Name']; + + // Since this is just a temp file we don't want to nest it in a bunch of subfolders. + $localPath = paths(PATH_UPLOADS, 'tmp-static', str_replace('/', '-', $parsed['Name'])); + + // Make sure the destination path exists + if (!file_exists(dirname($localPath))) { + mkdir(dirname($localPath), 0777, true); + } + + // Copy + copy($remotePath, $localPath); + + $args['Path'] = $localPath; + } +} diff --git a/vanilla/applications/dashboard/settings/structure.php b/vanilla/applications/dashboard/settings/structure.php new file mode 100644 index 0000000..dca54c1 --- /dev/null +++ b/vanilla/applications/dashboard/settings/structure.php @@ -0,0 +1,1029 @@ +sql(); +$Construct = $Database->structure(); +$Px = $Database->DatabasePrefix; + +// Role Table +$Construct->table('Role'); + +$RoleTableExists = $Construct->tableExists(); +$RoleTypeExists = $Construct->columnExists('Type'); + +$Construct + ->primaryKey('RoleID') + ->column('Name', 'varchar(100)') + ->column('Description', 'varchar(500)', true) + ->column('Type', [RoleModel::TYPE_TOPCODER, RoleModel::TYPE_GUEST, RoleModel::TYPE_UNCONFIRMED, RoleModel::TYPE_APPLICANT, RoleModel::TYPE_MEMBER, RoleModel::TYPE_MODERATOR, RoleModel::TYPE_ADMINISTRATOR], true) + ->column('Sort', 'int', true) + ->column('Deletable', 'tinyint(1)', '1') + ->column('CanSession', 'tinyint(1)', '1') + ->column('PersonalInfo', 'tinyint(1)', '0') + ->set($Explicit, $Drop); + +$RoleModel = new RoleModel(); + +if (!$RoleTableExists || $Drop) { + // Define default roles. + $RoleModel->Database = $Database; + $RoleModel->SQL = $SQL; + $Sort = 1; + $RoleModel->define(['Name' => 'Guest', 'Type' => RoleModel::TYPE_GUEST, 'RoleID' => 2, 'Sort' => $Sort++, 'Deletable' => '0', 'CanSession' => '0', 'Description' => t('Guest Role Description', 'Guests can only view content. Anyone browsing the site who is not signed in is considered to be a "Guest".')]); + $RoleModel->define(['Name' => 'Unconfirmed', 'Type' => RoleModel::TYPE_UNCONFIRMED, 'RoleID' => 3, 'Sort' => $Sort++, 'Deletable' => '0', 'CanSession' => '1', 'Description' => t('Unconfirmed Role Description', 'Users must confirm their emails before becoming full members. They get assigned to this role.')]); + $RoleModel->define(['Name' => 'Applicant', 'Type' => RoleModel::TYPE_APPLICANT, 'RoleID' => 4, 'Sort' => $Sort++, 'Deletable' => '0', 'CanSession' => '1', 'Description' => t('Applicant Role Description', 'Users who have applied for membership, but have not yet been accepted. They have the same permissions as guests.')]); + $RoleModel->define(['Name' => 'Member', 'Type' => RoleModel::TYPE_MEMBER, 'RoleID' => 8, 'Sort' => $Sort++, 'Deletable' => '1', 'CanSession' => '1', 'Description' => t('Member Role Description', 'Members can participate in discussions.')]); + $RoleModel->define(['Name' => 'Moderator', 'Type' => RoleModel::TYPE_MODERATOR, 'RoleID' => 32, 'Sort' => $Sort++, 'Deletable' => '1', 'CanSession' => '1', 'Description' => t('Moderator Role Description', 'Moderators have permission to edit most content.')]); + $RoleModel->define(['Name' => 'VanillaAdmin', 'Type' => RoleModel::TYPE_ADMINISTRATOR, 'RoleID' => 16, 'Sort' => $Sort++, 'Deletable' => '1', 'CanSession' => '1', 'Description' => t('Administrator Role Description', 'Administrators have permission to do anything.')]); + $nextRoleId = 33; + foreach (RoleModel::TOPCODER_ROLES as $key => $values ) { + $RoleModel->define(['Name' => $key, 'Type' => RoleModel::ROLE_TYPE_TOPCODER, 'RoleID' => $nextRoleId++, 'Sort' => $Sort++, 'Deletable' => '1', 'CanSession' => '1', 'Description' => 'Topcoder role']); + } + + foreach (RoleModel::TOPCODER_PROJECT_ROLES as $key => $values ) { + $RoleModel->define(['Name' => $key, 'Type' => RoleModel::ROLE_TYPE_TOPCODER, 'RoleID' => $nextRoleId++, 'Sort' => $Sort++, 'Deletable' => '1', 'CanSession' => '1', 'Description' => 'Topcoder Project role']); + } + +} + +// User Table +$Construct->table('User'); + +$PhotoIDExists = $Construct->columnExists('PhotoID'); +$PhotoExists = $Construct->columnExists('Photo'); +$UserExists = $Construct->tableExists(); +$ConfirmedExists = $Construct->columnExists('Confirmed'); +$AllIPAddressesExists = $Construct->columnExists('AllIPAddresses'); + +$Construct + ->primaryKey('UserID') + ->column('Name', 'varchar(50)', false, 'key') + ->column('Password', 'varbinary(100)')// keep this longer because of some imports. + ->column('HashMethod', 'varchar(10)', true) + ->column('Photo', 'varchar(255)', null) + ->column('Title', 'varchar(100)', null) + ->column('Location', 'varchar(100)', null) + ->column('About', 'text', true) + ->column('Email', 'varchar(100)', false, 'index') + ->column('ShowEmail', 'tinyint(1)', '0') + ->column('Gender', ['u', 'm', 'f'], 'u') + ->column('CountVisits', 'int', '0') + ->column('CountInvitations', 'int', '0') + ->column('CountNotifications', 'int', null) + ->column('InviteUserID', 'int', true) + ->column('DiscoveryText', 'text', true) + ->column('Preferences', 'text', true) + ->column('Permissions', 'text', true) + ->column('Attributes', 'text', true) + ->column('DateSetInvitations', 'datetime', true) + ->column('DateOfBirth', 'datetime', true) + ->column('DateFirstVisit', 'datetime', true) + ->column('DateLastActive', 'datetime', true, 'index') + ->column('LastIPAddress', 'ipaddress', true) + ->column('DateInserted', 'datetime', false, 'index') + ->column('InsertIPAddress', 'ipaddress', true) + ->column('DateUpdated', 'datetime', true) + ->column('UpdateIPAddress', 'ipaddress', true) + ->column('HourOffset', 'int', '0') + ->column('Score', 'float', null) + ->column('Admin', 'tinyint(1)', '0') + ->column('Confirmed', 'tinyint(1)', '1')// 1 means email confirmed, otherwise not confirmed + ->column('Verified', 'tinyint(1)', '0')// 1 means verified (non spammer), otherwise not verified + ->column('Banned', 'tinyint(1)', '0')// 1 means banned, otherwise not banned + ->column('Deleted', 'tinyint(1)', '0') + ->column('Points', 'int', 0) + ->set($Explicit, $Drop); + +// Modify all users with ConfirmEmail role to be unconfirmed +if ($UserExists && !$ConfirmedExists) { + $ConfirmEmailRoleID = RoleModel::getDefaultRoles(RoleModel::TYPE_UNCONFIRMED); + if (UserModel::requireConfirmEmail() && !empty($ConfirmEmailRoleID)) { + // Select unconfirmed users + $Users = Gdn::sql()->select('UserID')->from('UserRole')->where('RoleID', $ConfirmEmailRoleID)->get(); + $UserIDs = []; + while ($User = $Users->nextRow(DATASET_TYPE_ARRAY)) { + $UserIDs[] = $User['UserID']; + } + + // Update + Gdn::sql()->update('User')->set('Confirmed', 0)->whereIn('UserID', $UserIDs)->put(); + Gdn::sql()->delete('UserRole', ['RoleID' => $ConfirmEmailRoleID, 'UserID' => $UserIDs]); + } +} + +// Make sure the system user is okay. +$SystemUserID = c('Garden.SystemUserID'); +if ($SystemUserID) { + $SysUser = Gdn::userModel()->getID($SystemUserID); + + if (!$SysUser || val('Deleted', $SysUser) || val('Admin', $SysUser) != 2) { + $SystemUserID = false; + removeFromConfig('Garden.SystemUserID'); + } +} + +if (!$SystemUserID) { + // Try and find a system user. + $SystemUserID = Gdn::sql()->getWhere('User', ['Name' => 'System', 'Admin' => 2])->value('UserID'); + if ($SystemUserID) { + saveToConfig('Garden.SystemUserID', $SystemUserID); + } else { + // Create a new one if we couldn't find one. + Gdn::userModel()->getSystemUserID(); + } +} + +// Make sure there is an update token. +if (empty(Gdn::config('Garden.UpdateToken'))) { + Gdn::config()->saveToConfig('Garden.UpdateToken', \Vanilla\Models\InstallModel::generateUpdateToken()); +} + +// UserIP Table +$Construct->table('UserIP') + ->column('UserID', 'int', false, 'primary') + ->column('IPAddress', 'varbinary(16)', false, 'primary') + ->column('DateInserted', 'datetime', false) + ->column('DateUpdated', 'datetime', false) + ->set($Explicit, $Drop); + +// UserRole Table +$Construct->table('UserRole'); + +$UserRoleExists = $Construct->tableExists(); + +$Construct + ->column('UserID', 'int', false, 'primary') + ->column('RoleID', 'int', false, ['primary', 'index']) + ->set($Explicit, $Drop); + +// Fix old default roles that were stored in the config and user-role table. +if ($RoleTableExists && $UserRoleExists && $RoleTypeExists) { + $types = $RoleModel->getAllDefaultRoles(); + + // Mapping of legacy config keys to new role types. + $legacyRoleConfig = [ + 'Garden.Registration.ApplicantRoleID' => RoleModel::TYPE_APPLICANT, + 'Garden.Registration.ConfirmEmailRole' => RoleModel::TYPE_UNCONFIRMED, + 'Garden.Registration.DefaultRoles' => RoleModel::TYPE_MEMBER + ]; + + // Loop through our old config values and update their associated roles with the proper type. + foreach ($legacyRoleConfig as $roleConfig => $roleType) { + if (c($roleConfig) && !empty($types[$roleType])) { + $SQL->update('Role') + ->set('Type', $roleType) + ->whereIn('RoleID', $types[$roleType]) + ->put(); + + if (!$captureOnly) { + // No need for this anymore. + removeFromConfig($roleConfig); + } + } + } + + $guestRoleIDs = Gdn::sql()->getWhere('UserRole', ['UserID' => 0])->resultArray(); + if (!empty($guestRoleIDs)) { + $SQL->update('Role') + ->set('Type', RoleModel::TYPE_GUEST) + ->where('RoleID', $types[RoleModel::TYPE_GUEST]) + ->put(); + + $SQL->delete('UserRole', ['UserID' => 0]); + } +} + +if (!$UserRoleExists) { + // Assign the admin user to admin role. + $adminRoleIDs = RoleModel::getDefaultRoles(RoleModel::TYPE_ADMINISTRATOR); + + foreach ($adminRoleIDs as $id) { + $SQL->replace('UserRole', [], ['UserID' => 1, 'RoleID' => $id]); + } +} + +// User Meta Table +$Construct->table('UserMeta') + ->column('UserID', 'int', false, 'primary') + ->column('Name', 'varchar(100)', false, ['primary', 'index']) + ->column('Value', 'text', true) + ->set($Explicit, $Drop); + +// User Points Table +$Construct->table('UserPoints') + ->column('SlotType', ['d', 'w', 'm', 'y', 'a'], false, 'primary') + ->column('TimeSlot', 'datetime', false, 'primary') + ->column('Source', 'varchar(10)', 'Total', 'primary') + ->column('CategoryID', 'int', 0, 'primary') + ->column('UserID', 'int', false, 'primary') + ->column('Points', 'int', 0) + ->set($Explicit, $Drop); + +// Create the authentication table. +$Construct->table('UserAuthentication') + ->column('ForeignUserKey', 'varchar(100)', false, 'primary') + ->column('ProviderKey', 'varchar(64)', false, 'primary') + ->column('UserID', 'int', false, 'key') + ->set($Explicit, $Drop); + +$Construct->table('UserAuthenticationProvider') + ->column('AuthenticationKey', 'varchar(64)', false, 'primary') + ->column('AuthenticationSchemeAlias', 'varchar(32)', false) + ->column('Name', 'varchar(50)', true) + ->column('URL', 'varchar(255)', true) + ->column('AssociationSecret', 'text', true) + ->column('AssociationHashMethod', 'varchar(20)', true) + ->column('AuthenticateUrl', 'varchar(255)', true) + ->column('RegisterUrl', 'varchar(255)', true) + ->column('SignInUrl', 'varchar(255)', true) + ->column('SignOutUrl', 'varchar(255)', true) + ->column('PasswordUrl', 'varchar(255)', true) + ->column('ProfileUrl', 'varchar(255)', true) + ->column('Attributes', 'text', true) + ->column('Active', 'tinyint', '1') + ->column('IsDefault', 'tinyint', 0) + ->set($Explicit, $Drop); + +$Construct->table('UserAuthenticationNonce') + ->column('Nonce', 'varchar(100)', false, 'primary') + ->column('Token', 'varchar(128)', false) + ->column('Timestamp', 'timestamp', false, 'index') + ->set($Explicit, $Drop); + +$Construct->table('UserAuthenticationToken') + ->column('Token', 'varchar(128)', false, 'primary') + ->column('ProviderKey', 'varchar(50)', false, 'primary') + ->column('ForeignUserKey', 'varchar(100)', true) + ->column('TokenSecret', 'varchar(64)', false) + ->column('TokenType', ['request', 'access'], false) + ->column('Authorized', 'tinyint(1)', false) + ->column('Timestamp', 'timestamp', false ,'index') + ->column('Lifetime', 'int', false) + ->set($Explicit, $Drop); + +if ($captureOnly === false && $Construct->tableExists('AccessToken') && $Construct->table('AccessToken')->columnExists('AccessTokenID') === false) { + $accessTokenTable = $SQL->prefixTable('AccessToken'); + try { + $SQL->query("alter table {$accessTokenTable} drop primary key"); + } catch (Exception $e) { + // Primary key doesn't exist. Nothing to do here. + } + $SQL->query("alter table {$accessTokenTable} add AccessTokenID int not null auto_increment primary key first"); +} + +$Construct + ->table('AccessToken') + ->primaryKey('AccessTokenID') + ->column('Token', 'varchar(100)', false, 'unique') + ->column('UserID', 'int', false, 'index') + ->column('Type', 'varchar(20)', false, 'index') + ->column('Scope', 'text', true) + ->column('DateInserted', 'timestamp', ['Null' => false, 'Default' => 'current_timestamp']) + ->column('InsertUserID', 'int', true) + ->column('InsertIPAddress', 'ipaddress', false) + ->column('DateExpires', 'timestamp', ['Null' => false, 'Default' => 'current_timestamp'], 'index') + ->column('Attributes', 'text', true) + ->set($Explicit, $Drop); + +// Fix the sync roles config spelling mistake. +if (c('Garden.SSO.SynchRoles')) { + saveToConfig( + ['Garden.SSO.SynchRoles' => '', 'Garden.SSO.SyncRoles' => c('Garden.SSO.SynchRoles')], + '', + ['RemoveEmpty' => true] + ); +} + + +$Construct->table('Session'); + +$transientKeyExists = $Construct->columnExists('TransientKey'); +if ($transientKeyExists) { + $Construct->dropColumn('TransientKey'); +} +$dateExpireExists = $Construct->columnExists('DateExpire'); +if ($dateExpireExists) { + $Construct->renameColumn('DateExpire', 'DateExpires'); +} + +$Construct + ->column('SessionID', 'char(32)', false, 'primary') + ->column('UserID', 'int', 0) + ->column('DateInserted', 'datetime', false) + ->column('DateUpdated', 'datetime', null); +if (!$dateExpireExists) { + $Construct->column('DateExpires', 'datetime', null, 'index'); +} +$Construct + ->column('Attributes', 'text', null) + ->set($Explicit, $Drop); + +$Construct->table('AnalyticsLocal') + ->engine('InnoDB') + ->column('TimeSlot', 'varchar(8)', false, 'unique') + ->column('Views', 'int', null) + ->column('EmbedViews', 'int', true) + ->set(false, false); + +$uploadPermission = 'Garden.Uploads.Add'; +$uploadPermissionExists = $Construct->table('Permission')->columnExists($uploadPermission); + +// Only Create the permission table if we are using Garden's permission model. +$PermissionModel = Gdn::permissionModel(); +$PermissionModel->Database = $Database; +$PermissionModel->SQL = $SQL; +$PermissionTableExists = false; +if ($PermissionModel instanceof PermissionModel) { + $PermissionTableExists = $Construct->tableExists('Permission'); + + // Permission Table + $Construct->table('Permission') + ->primaryKey('PermissionID') + ->column('RoleID', 'int', 0, 'key') + ->column('JunctionTable', 'varchar(100)', true) + ->column('JunctionColumn', 'varchar(100)', true) + ->column('JunctionID', 'int', true) + // The actual permissions will be added by PermissionModel::define() + ->set($Explicit, $Drop); +} + +// Define the set of permissions that Garden uses. +$PermissionModel->define([ + 'Garden.Email.View' => 'Garden.SignIn.Allow', + 'Garden.Settings.Manage', + 'Garden.Settings.View', + 'Garden.SignIn.Allow' => 1, + 'Garden.Users.Add', + 'Garden.Users.Edit', + 'Garden.Users.Delete', + 'Garden.Users.Approve', + 'Garden.Activity.Delete', + 'Garden.Activity.View'=> 'Garden.SignIn.Allow', + 'Garden.Profiles.View'=> 'Garden.SignIn.Allow', + 'Garden.Profiles.Edit' => 'Garden.SignIn.Allow', + 'Garden.Curation.Manage' => 'Garden.Moderation.Manage', + 'Garden.Moderation.Manage', + 'Garden.PersonalInfo.View' => 'Garden.Moderation.Manage', + 'Garden.AdvancedNotifications.Allow', + 'Garden.Community.Manage' => 'Garden.Settings.Manage', + 'Garden.Tokens.Add' => 'Garden.Settings.Manage', + 'Groups.Group.Add', + 'Groups.Moderation.Manage', + 'Groups.EmailInvitations.Add', + 'Groups.Category.Manage', + $uploadPermission => 0 +]); + +$PermissionModel->undefine([ + 'Garden.Applications.Manage', + 'Garden.Email.Manage', + 'Garden.Plugins.Manage', + 'Garden.Registration.Manage', + 'Garden.Routes.Manage', + 'Garden.Themes.Manage', + 'Garden.Messages.Manage' +]); + +// Revoke the new upload permission from existing applicant, unconfirmed and guest roles. +if ($uploadPermissionExists === false) { + $revokeTypes = [RoleModel::TYPE_APPLICANT, RoleModel::TYPE_UNCONFIRMED, RoleModel::TYPE_GUEST, RoleModel::TYPE_TOPCODER, RoleModel::TYPE_MEMBER]; + + foreach ($revokeTypes as $revokeType) { + $revokeRoles = $RoleModel->getByType($revokeType)->resultArray(); + foreach ($revokeRoles as $revokeRole) { + $revokePermissions = $PermissionModel->getRolePermissions($revokeRole['RoleID']); + foreach ($revokePermissions as $revokePermission) { + $PermissionModel->save([ + 'PermissionID' => $revokePermission['PermissionID'], + $uploadPermission => 0 + ]); + } + } + } +} + +// Invitation Table +$Construct->table('Invitation') + ->primaryKey('InvitationID') + ->column('Email', 'varchar(100)', false, 'index') + ->column('Name', 'varchar(50)', true) + ->column('RoleIDs', 'text', true) + ->column('Code', 'varchar(50)', false, 'unique.code') + ->column('InsertUserID', 'int', true, 'index.userdate') + ->column('DateInserted', 'datetime', false, 'index.userdate') + ->column('AcceptedUserID', 'int', true) + ->column('DateAccepted', 'datetime', true) + ->column('DateExpires', 'datetime', true) + ->set($Explicit, $Drop); + +// Fix negative invitation expiry dates. +$InviteExpiry = c('Garden.Registration.InviteExpiration'); +if ($InviteExpiry && substr($InviteExpiry, 0, 1) === '-') { + $InviteExpiry = substr($InviteExpiry, 1); + saveToConfig('Garden.Registration.InviteExpiration', $InviteExpiry); +} + +// ActivityType Table +$Construct->table('ActivityType') + ->primaryKey('ActivityTypeID') + ->column('Name', 'varchar(20)') + ->column('AllowComments', 'tinyint(1)', '0') + ->column('ShowIcon', 'tinyint(1)', '0') + ->column('ProfileHeadline', 'varchar(255)', true) + ->column('FullHeadline', 'varchar(255)', true) + ->column('RouteCode', 'varchar(255)', true) + ->column('Notify', 'tinyint(1)', '0')// Add to RegardingUserID's notification list? + ->column('Public', 'tinyint(1)', '1')// Should everyone be able to see this, or just the RegardingUserID? + ->set($Explicit, $Drop); + +// Activity Table +// column($Name, $Type, $Length = '', $Null = FALSE, $Default = null, $KeyType = FALSE, $AutoIncrement = FALSE) + +$Construct->table('Activity'); +$ActivityExists = $Construct->tableExists(); +$NotifiedExists = $Construct->columnExists('Notified'); +$EmailedExists = $Construct->columnExists('Emailed'); +$CommentActivityIDExists = $Construct->columnExists('CommentActivityID'); +$NotifyUserIDExists = $Construct->columnExists('NotifyUserID'); +$DateUpdatedExists = $Construct->columnExists('DateUpdated'); + +if ($ActivityExists) { + $ActivityIndexes = $Construct->indexSqlDb(); +} else { + $ActivityIndexes = []; +} + +$Construct + ->primaryKey('ActivityID') + ->column('ActivityTypeID', 'int') + ->column('NotifyUserID', 'int', 0, ['index.Notify', 'index.Recent', 'index.Feed'])// user being notified or -1: public, -2 mods, -3 admins + ->column('ActivityUserID', 'int', true, 'index.Feed') + ->column('RegardingUserID', 'int', true)// deprecated? + ->column('Photo', 'varchar(255)', true) + ->column('HeadlineFormat', 'varchar(255)', true) + ->column('Story', 'text', true) + ->column('Format', 'varchar(10)', true) + ->column('Route', 'varchar(255)', true) + ->column('RecordType', 'varchar(20)', true) + ->column('RecordID', 'int', true) +// ->column('CountComments', 'int', '0') + ->column('InsertUserID', 'int', true, 'key') + ->column('DateInserted', 'datetime') + ->column('InsertIPAddress', 'ipaddress', true) + ->column('DateUpdated', 'datetime', !$DateUpdatedExists, ['index', 'index.Recent', 'index.Feed']) + ->column('Notified', 'tinyint(1)', 0, 'index.Notify') + ->column('Emailed', 'tinyint(1)', 0) + ->column('Data', 'text', true) + ->set($Explicit, $Drop); + +if (isset($ActivityIndexes['IX_Activity_NotifyUserID'])) { + $SQL->query("drop index IX_Activity_NotifyUserID on {$Px}Activity"); +} + +if (isset($ActivityIndexes['FK_Activity_ActivityUserID'])) { + $SQL->query("drop index FK_Activity_ActivityUserID on {$Px}Activity"); +} + +if (isset($ActivityIndexes['FK_Activity_RegardingUserID'])) { + $SQL->query("drop index FK_Activity_RegardingUserID on {$Px}Activity"); +} + +if (!$EmailedExists) { + $SQL->put('Activity', ['Emailed' => 1]); +} +if (!$NotifiedExists) { + $SQL->put('Activity', ['Notified' => 1]); +} + +if (!$DateUpdatedExists) { + $SQL->update('Activity') + ->set('DateUpdated', 'DateInserted', false, false) + ->put(); +} + +if (!$NotifyUserIDExists && $ActivityExists) { + // Update all of the activities that are notifications. + $SQL->update('Activity a') + ->join('ActivityType at', 'a.ActivityTypeID = at.ActivityTypeID') + ->set('a.NotifyUserID', 'a.RegardingUserID', false) + ->where('at.Notify', 1) + ->put(); + + // Update all public activities. + $SQL->update('Activity a') + ->join('ActivityType at', 'a.ActivityTypeID = at.ActivityTypeID') + ->set('a.NotifyUserID', ActivityModel::NOTIFY_PUBLIC) + ->where('at.Public', 1) + ->where('a.NotifyUserID', 0) + ->put(); + + $SQL->delete('Activity', ['NotifyUserID' => 0]); +} + +$ActivityCommentExists = $Construct->tableExists('ActivityComment'); + +$Construct + ->table('ActivityComment') + ->primaryKey('ActivityCommentID') + ->column('ActivityID', 'int', false, 'key') + ->column('Body', 'text') + ->column('Format', 'varchar(20)') + ->column('InsertUserID', 'int') + ->column('DateInserted', 'datetime') + ->column('InsertIPAddress', 'ipaddress', true) + ->set($Explicit, $Drop); + +// Move activity comments to the activity comment table. +if (!$ActivityCommentExists && $CommentActivityIDExists) { + $Q = "insert {$Px}ActivityComment (ActivityID, Body, Format, InsertUserID, DateInserted, InsertIPAddress) + select CommentActivityID, Story, 'Text', InsertUserID, DateInserted, InsertIPAddress + from {$Px}Activity + where CommentActivityID > 0"; + $SQL->query($Q); + $SQL->delete('Activity', ['CommentActivityID >' => 0]); +} + +// Insert some activity types +/// %1 = ActivityName +/// %2 = ActivityName Possessive: Username +/// %3 = RegardingName +/// %4 = RegardingName Possessive: Username, his, her, your +/// %5 = Link to RegardingName's Wall +/// %6 = his/her +/// %7 = he/she +/// %8 = RouteCode & Route +if ($SQL->getWhere('ActivityType', ['Name' => 'SignIn'])->numRows() == 0) { + $SQL->insert('ActivityType', ['AllowComments' => '0', 'Name' => 'SignIn', 'FullHeadline' => '%1$s signed in.', 'ProfileHeadline' => '%1$s signed in.']); +} +if ($SQL->getWhere('ActivityType', ['Name' => 'Join'])->numRows() == 0) { + $SQL->insert('ActivityType', ['AllowComments' => '1', 'Name' => 'Join', 'FullHeadline' => '%1$s joined.', 'ProfileHeadline' => '%1$s joined.']); +} +if ($SQL->getWhere('ActivityType', ['Name' => 'JoinInvite'])->numRows() == 0) { + $SQL->insert('ActivityType', ['AllowComments' => '1', 'Name' => 'JoinInvite', 'FullHeadline' => '%1$s accepted %4$s invitation for membership.', 'ProfileHeadline' => '%1$s accepted %4$s invitation for membership.']); +} +if ($SQL->getWhere('ActivityType', ['Name' => 'JoinApproved'])->numRows() == 0) { + $SQL->insert('ActivityType', ['AllowComments' => '1', 'Name' => 'JoinApproved', 'FullHeadline' => '%1$s approved %4$s membership application.', 'ProfileHeadline' => '%1$s approved %4$s membership application.']); +} +$SQL->replace('ActivityType', ['AllowComments' => '1', 'FullHeadline' => '%1$s created an account for %3$s.', 'ProfileHeadline' => '%1$s created an account for %3$s.'], ['Name' => 'JoinCreated'], true); + +if ($SQL->getWhere('ActivityType', ['Name' => 'AboutUpdate'])->numRows() == 0) { + $SQL->insert('ActivityType', ['AllowComments' => '1', 'Name' => 'AboutUpdate', 'FullHeadline' => '%1$s updated %6$s profile.', 'ProfileHeadline' => '%1$s updated %6$s profile.']); +} +if ($SQL->getWhere('ActivityType', ['Name' => 'WallComment'])->numRows() == 0) { + $SQL->insert('ActivityType', ['AllowComments' => '1', 'ShowIcon' => '1', 'Name' => 'WallComment', 'FullHeadline' => '%1$s wrote on %4$s %5$s.', 'ProfileHeadline' => '%1$s wrote:']); +} +if ($SQL->getWhere('ActivityType', ['Name' => 'PictureChange'])->numRows() == 0) { + $SQL->insert('ActivityType', ['AllowComments' => '1', 'Name' => 'PictureChange', 'FullHeadline' => '%1$s changed %6$s profile picture.', 'ProfileHeadline' => '%1$s changed %6$s profile picture.']); +} +//if ($SQL->getWhere('ActivityType', array('Name' => 'RoleChange'))->numRows() == 0) +$SQL->replace('ActivityType', ['AllowComments' => '1', 'FullHeadline' => '%1$s changed %4$s permissions.', 'ProfileHeadline' => '%1$s changed %4$s permissions.', 'Notify' => '1'], ['Name' => 'RoleChange'], true); +if ($SQL->getWhere('ActivityType', ['Name' => 'ActivityComment'])->numRows() == 0) { + $SQL->insert('ActivityType', ['AllowComments' => '0', 'ShowIcon' => '1', 'Name' => 'ActivityComment', 'FullHeadline' => '%1$s commented on %4$s %8$s.', 'ProfileHeadline' => '%1$s', 'RouteCode' => 'activity', 'Notify' => '1']); +} +if ($SQL->getWhere('ActivityType', ['Name' => 'Import'])->numRows() == 0) { + $SQL->insert('ActivityType', ['AllowComments' => '0', 'Name' => 'Import', 'FullHeadline' => '%1$s imported data.', 'ProfileHeadline' => '%1$s imported data.', 'Notify' => '1', 'Public' => '0']); +} +//if ($SQL->getWhere('ActivityType', array('Name' => 'Banned'))->numRows() == 0) +$SQL->replace('ActivityType', ['AllowComments' => '0', 'FullHeadline' => '%1$s banned %3$s.', 'ProfileHeadline' => '%1$s banned %3$s.', 'Notify' => '0', 'Public' => '1'], ['Name' => 'Banned'], true); +//if ($SQL->getWhere('ActivityType', array('Name' => 'Unbanned'))->numRows() == 0) +$SQL->replace('ActivityType', ['AllowComments' => '0', 'FullHeadline' => '%1$s un-banned %3$s.', 'ProfileHeadline' => '%1$s un-banned %3$s.', 'Notify' => '0', 'Public' => '1'], ['Name' => 'Unbanned'], true); + +// Applicant activity +if ($SQL->getWhere('ActivityType', ['Name' => 'Applicant'])->numRows() == 0) { + $SQL->insert('ActivityType', ['AllowComments' => '0', 'Name' => 'Applicant', 'FullHeadline' => '%1$s applied for membership.', 'ProfileHeadline' => '%1$s applied for membership.', 'Notify' => '1', 'Public' => '0']); +} + +$WallPostType = $SQL->getWhere('ActivityType', ['Name' => 'WallPost'])->firstRow(DATASET_TYPE_ARRAY); +if (!$WallPostType) { + $WallPostTypeID = $SQL->insert('ActivityType', ['AllowComments' => '1', 'ShowIcon' => '1', 'Name' => 'WallPost', 'FullHeadline' => '%3$s wrote on %2$s %5$s.', 'ProfileHeadline' => '%3$s wrote:']); + $WallCommentTypeID = $SQL->getWhere('ActivityType', ['Name' => 'WallComment'])->value('ActivityTypeID'); + + // Update all old wall comments to wall posts. + $SQL->update('Activity') + ->set('ActivityTypeID', $WallPostTypeID) + ->set('ActivityUserID', 'RegardingUserID', false) + ->set('RegardingUserID', 'InsertUserID', false) + ->where('ActivityTypeID', $WallCommentTypeID) + ->where('RegardingUserID is not null') + ->put(); +} + +$ActivityModel = new ActivityModel(); +$ActivityModel->defineType('Default'); +$ActivityModel->defineType('Registration'); +$ActivityModel->defineType('Status'); +$ActivityModel->defineType('Ban'); + +// Message Table +$Construct->table('Message') + ->primaryKey('MessageID') + ->column('Content', 'text') + ->column('Format', 'varchar(20)', true) + ->column('AllowDismiss', 'tinyint(1)', '1') + ->column('Enabled', 'tinyint(1)', '1') + ->column('Application', 'varchar(255)', true) + ->column('Controller', 'varchar(255)', true) + ->column('Method', 'varchar(255)', true) + ->column('CategoryID', 'int', true) + ->column('IncludeSubcategories', 'tinyint', '0') + ->column('AssetTarget', 'varchar(20)', true) + ->column('CssClass', 'varchar(20)', true) + ->column('Sort', 'int', true) + ->set($Explicit, $Drop); + +$Prefix = $SQL->Database->DatabasePrefix; + +if ($PhotoIDExists && !$PhotoExists) { + $SQL->query("update {$Prefix}User u + join {$Prefix}Photo p + on u.PhotoID = p.PhotoID + set u.Photo = p.Name"); +} + +if ($PhotoIDExists) { + $Construct->table('User')->dropColumn('PhotoID'); +} + +$Construct->table('Tag'); +$FullNameColumnExists = $Construct->columnExists('FullName'); +$TagCategoryColumnExists = $Construct->columnExists('CategoryID'); + +// This is a fix for erroneous unique constraint. +if ($Construct->tableExists('Tag') && $TagCategoryColumnExists) { + $Db = Gdn::database(); + $Px = Gdn::database()->DatabasePrefix; + + $DupTags = Gdn::sql() + ->select('Name, CategoryID') + ->select('TagID', 'min', 'FirstTagID') + ->from('Tag') + ->groupBy('Name') + ->groupBy('CategoryID') + ->having('count(TagID) >', 1, false) + ->get()->resultArray(); + + foreach ($DupTags as $Row) { + $Name = $Row['Name']; + $CategoryID = $Row['CategoryID']; + $TagID = $Row['FirstTagID']; + // Get the tags that need to be deleted. + $DeleteTags = Gdn::sql()->getWhere('Tag', ['Name' => $Name, 'CategoryID' => $CategoryID, 'TagID <> ' => $TagID])->resultArray(); + foreach ($DeleteTags as $DRow) { + // Update all of the discussions to the new tag. + Gdn::sql()->options('Ignore', true)->put( + 'TagDiscussion', + ['TagID' => $TagID], + ['TagID' => $DRow['TagID']] + ); + + // Delete the tag. + Gdn::sql()->delete('Tag', ['TagID' => $DRow['TagID']]); + } + } +} + +$Construct->table('Tag') + ->primaryKey('TagID') + ->column('Name', 'varchar(100)', false, 'unique') + ->column('FullName', 'varchar(100)', !$FullNameColumnExists, 'index') + ->column('Type', 'varchar(20)', '', 'index') + ->column('ParentTagID', 'int', true, 'key') + ->column('InsertUserID', 'int', true, 'key') + ->column('DateInserted', 'datetime') + ->column('CategoryID', 'int', -1, 'unique') + ->engine('InnoDB') + ->set($Explicit, $Drop); + +if (!$FullNameColumnExists) { + Gdn::sql()->update('Tag') + ->set('FullName', 'Name', false, false) + ->put(); + + $Construct->table('Tag') + ->column('FullName', 'varchar(100)', false, 'index') + ->set(); +} + +// Tag moved from plugins to core. +$Construct->table('Permission'); +if ($Construct->columnExists('Plugins.Tagging.Add')) { + // Rename the permission column. + $Construct->renameColumn('Plugins.Tagging.Add', 'Vanilla.Tagging.Add'); + + // Update the configurations. + $configNotFound = Gdn::config()->NotFound; + $configs = [ + 'Vanilla.Tagging.CategorySearch' => 'Plugins.Tagging.CategorySearch', + 'Vanilla.Tagging.DisableInline' => 'Plugins.Tagging.DisableInline', + 'Tagging.Discussions.Enabled' => 'EnabledPlugins.Tagging', + 'Vanilla.Tagging.Max' => 'Plugin.Tagging.Max', // Missing s is not a typo + 'Vanilla.Tagging.Required' => 'Plugins.Tagging.Required', + 'Vanilla.Tagging.ShowLimit' => 'Plugins.Tagging.ShowLimit', + 'Vanilla.Tagging.StringTags' => 'Plugins.Tagging.StringTags', + 'Vanilla.Tagging.UseCategories' => 'Plugins.Tagging.UseCategories', + ]; + foreach ($configs as $newConfig => $oldConfig) { + if (Gdn::config()->find($oldConfig, false) !== $configNotFound) { + \Gdn::config()->touch($newConfig, c($oldConfig)); + } + } +} else if (!$Construct->columnExists('Vanilla.Tagging.Add')) { + $PermissionModel->define(['Vanilla.Tagging.Add' => 'Garden.Profiles.Edit']); +} + +$Construct->table('Log') + ->primaryKey('LogID') + ->column('Operation', ['Delete', 'Edit', 'Spam', 'Moderate', 'Pending', 'Ban', 'Error'], false, 'index') + ->column('RecordType', ['Discussion', 'Comment', 'User', 'Registration', 'Activity', 'ActivityComment', 'Configuration', 'Group', 'Event'], false, 'index') + ->column('TransactionLogID', 'int', null) + ->column('RecordID', 'int', null, 'index') + ->column('RecordUserID', 'int', null, 'index')// user responsible for the record; indexed for user deletion + ->column('RecordDate', 'datetime') + ->column('RecordIPAddress', 'ipaddress', null, 'index') + ->column('InsertUserID', 'int')// user that put record in the log + ->column('DateInserted', 'datetime', false, 'index')// date item added to log + ->column('InsertIPAddress', 'ipaddress', null) + ->column('OtherUserIDs', 'varchar(255)', null) + ->column('DateUpdated', 'datetime', null) + ->column('ParentRecordID', 'int', null, 'index') + ->column('CategoryID', 'int', null, 'key') + ->column('Data', 'mediumtext', null)// the data from the record. + ->column('CountGroup', 'int', null) + ->engine('InnoDB') + ->set($Explicit, $Drop); + +$Construct->table('Regarding') + ->primaryKey('RegardingID') + ->column('Type', 'varchar(100)', false, 'key') + ->column('InsertUserID', 'int', false) + ->column('DateInserted', 'datetime', false) + ->column('ForeignType', 'varchar(32)', false) + ->column('ForeignID', 'int(11)', false) + ->column('OriginalContent', 'text', true) + ->column('ParentType', 'varchar(32)', true) + ->column('ParentID', 'int(11)', true) + ->column('ForeignURL', 'varchar(255)', true) + ->column('Comment', 'text', false) + ->column('Reports', 'int(11)', true) + ->engine('InnoDB') + ->set($Explicit, $Drop); + +$Construct->table('Ban') + ->primaryKey('BanID') + ->column('BanType', ['IPAddress', 'Name', 'Email'], false, 'unique') + ->column('BanValue', 'varchar(50)', false, 'unique') + ->column('Notes', 'varchar(255)', null) + ->column('CountUsers', 'uint', 0) + ->column('CountBlockedRegistrations', 'uint', 0) + ->column('InsertUserID', 'int') + ->column('DateInserted', 'datetime') + ->column('InsertIPAddress', 'ipaddress', true) + ->column('UpdateUserID', 'int', true) + ->column('DateUpdated', 'datetime', true) + ->column('UpdateIPAddress', 'ipaddress', true) + ->engine('InnoDB') + ->set($Explicit, $Drop); + +$Construct->table('Spammer') + ->column('UserID', 'int', false, 'primary') + ->column('CountSpam', 'usmallint', 0) + ->column('CountDeletedSpam', 'usmallint', 0) + ->set($Explicit, $Drop); + +$Construct + ->table('Media') + ->primaryKey('MediaID') + ->column('Name', 'varchar(255)') + ->column('Path', 'varchar(255)') + ->column('Type', 'varchar(128)') + ->column('Size', 'int(11)') + ->column('Active', 'tinyint', 1) + ->column('InsertUserID', 'int(11)', false, 'index') + ->column('DateInserted', 'datetime') + ->column('ForeignID', 'int(11)', true, 'index.Foreign') + ->column('ForeignTable', 'varchar(24)', true, 'index.Foreign') + ->column('ImageWidth', 'usmallint', null) + ->column('ImageHeight', 'usmallint', null) + ->column('ThumbWidth', 'usmallint', null) + ->column('ThumbHeight', 'usmallint', null) + ->column('ThumbPath', 'varchar(255)', null) + ->set(false, false); + +// Merge backup. +$Construct + ->table('UserMerge') + ->primaryKey('MergeID') + ->column('OldUserID', 'int', false, 'key') + ->column('NewUserID', 'int', false, 'key') + ->column('DateInserted', 'datetime') + ->column('InsertUserID', 'int') + ->column('DateUpdated', 'datetime', true) + ->column('UpdateUserID', 'int', true) + ->column('Attributes', 'text', true) + ->set(); + +$Construct + ->table('UserMergeItem') + ->column('MergeID', 'int', false, 'key') + ->column('Table', 'varchar(30)') + ->column('Column', 'varchar(30)') + ->column('RecordID', 'int') + ->column('OldUserID', 'int') + ->column('NewUserID', 'int') + ->set(); + +$Construct + ->table('Attachment') + ->primaryKey('AttachmentID') + ->column('Type', 'varchar(64)')// ex: zendesk-case, vendor-item + ->column('ForeignID', 'varchar(50)', false, 'index')// ex: d-123 for DiscussionID 123, u-555 for UserID 555 + ->column('ForeignUserID', 'int', false, 'key')// the user id of the record we are attached to (de-normalization) + ->column('Source', 'varchar(64)')// ex: Zendesk, Vendor + ->column('SourceID', 'varchar(32)')// ex: 1 + ->column('SourceURL', 'varchar(255)') + ->column('Attributes', 'text', true) + ->column('DateInserted', 'datetime') + ->column('InsertUserID', 'int', false, 'key') + ->column('InsertIPAddress', 'ipaddress') + ->column('DateUpdated', 'datetime', true) + ->column('UpdateUserID', 'int', true) + ->column('UpdateIPAddress', 'ipaddress', true) + ->set($Explicit, $Drop); + +$Construct + ->table("contentDraft") + ->primaryKey("draftID") + ->column("recordType", "varchar(64)", false, ["index", "index.record", "index.parentRecord"]) + ->column("recordID", "int", true, "index.record") + ->column("parentRecordID", "int", true, "index.parentRecord") + ->column("attributes", "mediumtext") + ->column("insertUserID", "int", false, "index") + ->column("dateInserted", "datetime") + ->column("updateUserID", "int") + ->column("dateUpdated", "datetime") + ->set($Explicit, $Drop); + +$Construct + ->table("reaction") + ->primaryKey("reactionID") + ->column("reactionOwnerID", "int", false, ["index", "index.record"]) + ->column("recordID", "int", false, "index.record") + ->column("reactionValue", "int", false) + ->column("insertUserID", "int", false, ["index"]) + ->column("dateInserted", "datetime") + ->set($Explicit, $Drop); + +$Construct + ->table("reactionOwner") + ->primaryKey("reactionOwnerID") + ->column("ownerType", "varchar(64)", false, ["index", "unique.record"]) + ->column("reactionType", "varchar(64)", false, ["index", "unique.record"]) + ->column("recordType", "varchar(64)", false, ["index", "unique.record"]) + ->column("insertUserID", "int", false, ["index"]) + ->column("dateInserted", "datetime") + ->set($Explicit, $Drop); + +// If the AllIPAddresses column exists, attempt to migrate legacy IP data to the UserIP table. +if (!$captureOnly && $AllIPAddressesExists) { + $limit = 10000; + $resetBatch = 100; + + // Grab the initial batch of users. + $legacyIPAddresses = $SQL->select(['UserID', 'AllIPAddresses', 'InsertIPAddress', 'LastIPAddress', 'DateLastActive']) + ->from('User')->where('AllIPAddresses is not null')->limit($limit) + ->get()->resultArray(); + + do { + $processedUsers = []; + + // Iterate through the records of users with data needing to be migrated. + foreach ($legacyIPAddresses as $currentLegacy) { + // Pull out and format the relevant bits, where necessary. + $allIPAddresses = explode(',', $currentLegacy['AllIPAddresses']); + $dateLastActive = val('DateLastActive', $currentLegacy); + $insertIPAddress = val('InsertIPAddress', $currentLegacy); + $lastIPAddress = val('LastIPAddress', $currentLegacy); + $userID = val('UserID', $currentLegacy); + + // If we have a LastIPAddress record, use it. Give it a DateUpdated of the user's DateLastActive. + if (!empty($lastIPAddress)) { + Gdn::userModel()->saveIP( + $userID, + $lastIPAddress, + $dateLastActive + ); + } + + // Only save InsertIPAddress if it differs from LastIPAddress and is in AllIPAddresses (to avoid admin IPs). + if ($insertIPAddress !== $lastIPAddress && in_array($insertIPAddress, $allIPAddresses)) { + Gdn::userModel()->saveIP( + $userID, + $insertIPAddress + ); + } + + // Record the processed user's ID. + $processedUsers[] = $userID; + + // Every X records (determined by $resetBatch), clear out the AllIPAddresses field for processed users. + if (count($processedUsers) > 0 && (count($processedUsers) % $resetBatch) === 0) { + $SQL->update('User')->set('AllIPAddresses', null)->whereIn('UserID', $processedUsers) + ->limit(count($processedUsers))->put(); + } + } + + // Any stragglers that need to be wiped out? + if (count($processedUsers) > 0) { + $SQL->update('User')->set('AllIPAddresses', null)->where('UserID', $processedUsers)->limit(count($processedUsers))->put(); + } + + // Query the next batch of users with IP data needing to be migrated. + $legacyIPAddresses = $SQL->select(['UserID', 'AllIPAddresses', 'InsertIPAddress', 'LastIPAddress', 'DateLastActive']) + ->from('User')->where('AllIPAddresses is not null')->limit($limit) + ->get()->resultArray(); + } while (count($legacyIPAddresses) > 0); + + unset($allIPAddresses, $dateLastActive, $insertIPAddress, $lastIPAddress, $userID, $processedUsers); +} + +// Save the current input formatter to the user's config. +// This will allow us to change the default later and grandfather existing forums in. +saveToConfig('Garden.InputFormatter', c('Garden.InputFormatter')); + +\Gdn::config()->touch('Garden.Email.Format', 'text'); + +// Make sure the default locale is in its canonical form. +$currentLocale = c('Garden.Locale'); +$canonicalLocale = Gdn_Locale::canonicalize($currentLocale); +if ($currentLocale !== $canonicalLocale) { + saveToConfig('Garden.Locale', $canonicalLocale); +} + +// We need to ensure that recaptcha is enabled if this site is upgrading from +// 2.2 and has never had a captcha plugin +\Gdn::config()->touch('EnabledPlugins.recaptcha', true); + +// Move recaptcha private key to plugin namespace. +if (c('Garden.Registration.CaptchaPrivateKey')) { + \Gdn::config()->touch('Recaptcha.PrivateKey', c('Garden.Registration.CaptchaPrivateKey')); + removeFromConfig('Garden.Registration.CaptchaPrivateKey'); +} + +// Move recaptcha public key to plugin namespace. +if (c('Garden.Registration.CaptchaPublicKey')) { + \Gdn::config()->touch('Recaptcha.PublicKey', c('Garden.Registration.CaptchaPublicKey')); + removeFromConfig('Garden.Registration.CaptchaPublicKey'); +} + +// Make sure the smarty folders exist. +touchFolder(PATH_CACHE.'/Smarty/cache'); +touchFolder(PATH_CACHE.'/Smarty/compile'); + +// For Touch Icon +if (c('Plugins.TouchIcon.Uploaded')) { + saveToConfig('Garden.TouchIcon', 'TouchIcon/apple-touch-icon.png'); + removeFromConfig('Plugins.TouchIcon.Uploaded'); +} + +// Remove AllowJSONP globally +if (c('Garden.AllowJSONP')) { + removeFromConfig('Garden.AllowJSONP'); +} + +// Avoid the mobile posts having the rich format fall through as the default when the addon is not enabled. +$mobileInputFormatter = Gdn::config()->get("Garden.MobileInputFormatter"); +$richEditorEnabled = Gdn::addonManager()->isEnabled("rich-editor", \Vanilla\Addon::TYPE_ADDON); +if ($mobileInputFormatter === "Rich" && $richEditorEnabled === false) { + Gdn::config()->set("Garden.MobileInputFormatter", Gdn::config()->get("Garden.InputFormatter")); +} + +Gdn::router()->setRoute('apple-touch-icon.png', 'utility/showtouchicon', 'Internal'); +Gdn::router()->setRoute("robots.txt", "/robots", "Internal"); +Gdn::router()->setRoute("utility/robots", "/robots", "Internal"); +Gdn::router()->setRoute("container.html", 'staticcontent/container', "Internal"); + +// Migrate rules from Sitemaps addon. +if (Gdn::config()->get("Robots.Rules") === false && $sitemapsRobotsRules = Gdn::config()->get("Sitemap.Robots.Rules")) { + Gdn::config()->set("Robots.Rules", $sitemapsRobotsRules); + Gdn::config()->remove("Sitemap.Robots.Rules"); +} diff --git a/vanilla/applications/vanilla/controllers/api/DiscussionsApiController.php b/vanilla/applications/vanilla/controllers/api/DiscussionsApiController.php index 919cbf2..260dd48 100644 --- a/vanilla/applications/vanilla/controllers/api/DiscussionsApiController.php +++ b/vanilla/applications/vanilla/controllers/api/DiscussionsApiController.php @@ -630,9 +630,10 @@ public function post(array $body) { $body = $in->validate($body); $categoryID = $body['categoryID']; $this->discussionModel->categoryPermission('Vanilla.Discussions.Add', $categoryID); - $this->fieldPermission($body, 'closed', 'Vanilla.Discussions.Close', $categoryID); - $this->fieldPermission($body, 'pinned', 'Vanilla.Discussions.Announce', $categoryID); - $this->fieldPermission($body, 'sink', 'Vanilla.Discussions.Sink', $categoryID); + //TODO: check it later + //$this->fieldPermission($body, 'closed', 'Vanilla.Discussions.Close', $categoryID); + //$this->fieldPermission($body, 'pinned', 'Vanilla.Discussions.Announce', $categoryID); + //$this->fieldPermission($body, 'sink', 'Vanilla.Discussions.Sink', $categoryID); $discussionData = ApiUtils::convertInputKeys($body); $id = $this->discussionModel->save($discussionData); diff --git a/vanilla/applications/vanilla/settings/class.hooks.php b/vanilla/applications/vanilla/settings/class.hooks.php new file mode 100644 index 0000000..dad38ba --- /dev/null +++ b/vanilla/applications/vanilla/settings/class.hooks.php @@ -0,0 +1,1160 @@ +rule(\Vanilla\Navigation\BreadcrumbModel::class) + ->addCall('addProvider', [new Reference(\Vanilla\Forum\Navigation\ForumBreadcrumbProvider::class)]) + ; + + $dic->rule(\Vanilla\Menu\CounterModel::class) + ->addCall('addProvider', [new Reference(\Vanilla\Forum\Menu\UserCounterProvider::class)]) + ; + } + + /** + * Add to valid media attachment types. + * + * @param \Garden\Schema\Schema $schema + */ + public function articlesPatchAttachmentSchema_init(\Garden\Schema\Schema $schema) { + $types = $schema->getField("properties.foreignType.enum"); + $types[] = "comment"; + $types[] = "discussion"; + $schema->setField("properties.foreignType.enum", $types); + } + + /** + * Verify the current user can attach a media item to a Vanilla post. + * + * @param bool $canAttach + * @param string $foreignType + * @param int $foreignID + * @return bool + */ + public function canAttachMedia_handler(bool $canAttach, string $foreignType, int $foreignID): bool { + switch ($foreignType) { + case "comment": + $model = new CommentModel(); + break; + case "discussion": + $model = new DiscussionModel(); + break; + default: + return $canAttach; + } + + $row = $model->getID($foreignID, DATASET_TYPE_ARRAY); + if (!$row) { + return false; + } + return ($row["InsertUserID"] === Gdn::session()->UserID || Gdn::session()->checkRankedPermission("Garden.Moderation.Manage")); + } + + /** + * Counter rebuilding. + * + * @param DbaController $sender + */ + public function dbaController_countJobs_handler($sender) { + $counts = [ + 'Discussion' => ['CountComments', 'FirstCommentID', 'LastCommentID', 'DateLastComment', 'LastCommentUserID'], + 'Category' => ['CountDiscussions', 'CountAllDiscussions', 'CountComments', 'CountAllComments', 'LastDiscussionID', 'LastCommentID', 'LastDateInserted'], + 'Tag' => ['CountDiscussions'], + ]; + + foreach ($counts as $table => $columns) { + foreach ($columns as $column) { + $name = "Recalculate $table.$column"; + $url = "/dba/counts.json?".http_build_query(['table' => $table, 'column' => $column]); + $sender->Data['Jobs'][$name] = $url; + } + } + } + + /** + * Delete all of the Vanilla related information for a specific user. + * + * @since 2.1 + * + * @param int $userID The ID of the user to delete. + * @param array $options An array of options: + * - DeleteMethod: One of delete, wipe, or NULL + */ + public function deleteUserData($userID, $options = [], &$data = null) { + $sql = Gdn::sql(); + + // Remove discussion watch records and drafts. + $sql->delete('UserDiscussion', ['UserID' => $userID]); + + Gdn::userModel()->getDelete('Draft', ['InsertUserID' => $userID], $data); + + // Comment deletion depends on method selected + $deleteMethod = val('DeleteMethod', $options, 'delete'); + if ($deleteMethod == 'delete') { + // Get a list of category IDs that has this user as the most recent poster. + $discussionCats = $sql + ->select('cat.CategoryID') + ->from('Category cat') + ->join('Discussion d', 'd.DiscussionID = cat.LastDiscussionID') + ->where('d.InsertUserID', $userID) + ->get()->resultArray(); + + $commentCats = $sql + ->select('cat.CategoryID') + ->from('Category cat') + ->join('Comment c', 'c.CommentID = cat.LastCommentID') + ->where('c.InsertUserID', $userID) + ->get()->resultArray(); + + $categoryIDs = array_unique(array_merge(array_column($discussionCats, 'CategoryID'), array_column($commentCats, 'CategoryID'))); + + // Grab all of the discussions that the user has engaged in. + $discussionIDs = $sql + ->select('DiscussionID') + ->from('Comment') + ->where('InsertUserID', $userID) + ->groupBy('DiscussionID') + ->get()->resultArray(); + $discussionIDs = array_column($discussionIDs, 'DiscussionID'); + + Gdn::userModel()->getDelete('Comment', ['InsertUserID' => $userID], $data); + + // Update the comment counts. + $commentCounts = $sql + ->select('DiscussionID') + ->select('CommentID', 'count', 'CountComments') + ->select('CommentID', 'max', 'LastCommentID') + ->whereIn('DiscussionID', $discussionIDs) + ->groupBy('DiscussionID') + ->get('Comment')->resultArray(); + + foreach ($commentCounts as $row) { + $sql->put( + 'Discussion', + ['CountComments' => $row['CountComments'] + 1, 'LastCommentID' => $row['LastCommentID']], + ['DiscussionID' => $row['DiscussionID']] + ); + } + + // Update the last user IDs. + $sql->update('Discussion d') + ->join('Comment c', 'd.LastCommentID = c.CommentID', 'left') + ->set('d.LastCommentUserID', 'c.InsertUserID', false, false) + ->set('d.DateLastComment', 'coalesce(c.DateInserted, d.DateInserted)', false, false) + ->whereIn('d.DiscussionID', $discussionIDs) + ->put(); + + // Update the last posts. + $discussions = $sql + ->whereIn('DiscussionID', $discussionIDs) + ->where('LastCommentUserID', $userID) + ->get('Discussion'); + + // Delete the user's discussions. + Gdn::userModel()->getDelete('Discussion', ['InsertUserID' => $userID], $data); + + // Update the appropriate recent posts in the categories. + $categoryModel = new CategoryModel(); + foreach ($categoryIDs as $categoryID) { + $categoryModel->setRecentPost($categoryID); + } + } elseif ($deleteMethod == 'wipe') { + // Erase the user's discussions. + $sql->update('Discussion') + ->set('Body', t('The user and all related content has been deleted.')) + ->set('Format', 'Deleted') + ->where('InsertUserID', $userID) + ->put(); + + $sql->update('Comment') + ->set('Body', t('The user and all related content has been deleted.')) + ->set('Format', 'Deleted') + ->where('InsertUserID', $userID) + ->put(); + } else { + // Leave comments + } + + // Remove the user's profile information related to this application + $sql->update('User') + ->set([ + 'CountDiscussions' => 0, + 'CountUnreadDiscussions' => 0, + 'CountComments' => 0, + 'CountDrafts' => 0, + 'CountBookmarks' => 0 + ]) + ->where('UserID', $userID) + ->put(); + } + + /** + * Add tag data to discussions. + * + * @param DiscussionController $sender + */ + public function discussionController_render_before($sender) { + $discussionID = $sender->data('Discussion.DiscussionID'); + if ($discussionID) { + // Get the tags on this discussion. + $tags = TagModel::instance()->getDiscussionTags($discussionID, TagModel::IX_EXTENDED); + + foreach ($tags as $key => $value) { + $sender->setData('Discussion.'.$key, $value); + } + } + } + + /** + * + * + * @param DiscussionController $sender + */ + public function discussionController_beforeCommentBody_handler($sender) { + Gdn::regarding()->beforeCommentBody($sender); + } + + /** + * Show tags after discussion body. + * + * @param DiscussionController $sender + */ + public function discussionController_afterDiscussionBody_handler($sender) { + /* */ + // Allow disabling of inline tags. + if (!c('Vanilla.Tagging.DisableInline', false)) { + if (!property_exists($sender->EventArguments['Object'], 'CommentID')) { + $discussionID = property_exists($sender, 'DiscussionID') ? $sender->DiscussionID : 0; + + if (!$discussionID) { + return; + } + + $tagModule = new TagModule($sender); + echo $tagModule->inlineDisplay(); + } + } + } + + /** + * Validate tags when saving a discussion. + * + * @param DiscussionModel $sender + * @param array $args + */ + public function discussionModel_beforeSaveDiscussion_handler($sender, $args) { + // Allow an addon to set disallowed tag names. + $reservedTags = []; + $sender->EventArguments['ReservedTags'] = &$reservedTags; + $sender->fireEvent('ReservedTags'); + + // Set some tagging requirements. + $tagsString = trim(strtolower(valr('FormPostValues.Tags', $args, ''))); + if (stringIsNullOrEmpty($tagsString) && c('Vanilla.Tagging.Required')) { + $sender->Validation->addValidationResult('Tags', 'You must specify at least one tag.'); + } else { + // Break apart our tags and lowercase them all for comparisons. + $tags = TagModel::splitTags($tagsString); + $tags = array_map('strtolower', $tags); + $reservedTags = array_map('strtolower', $reservedTags); + $maxTags = c('Vanilla.Tagging.Max', 5); + + // Validate our tags. + if ($reservedTags = array_intersect($tags, $reservedTags)) { + $names = implode(', ', $reservedTags); + $sender->Validation->addValidationResult('Tags', '@'.sprintf(t('These tags are reserved and cannot be used: %s'), $names)); + } + if (!TagModel::validateTags($tags)) { + $sender->Validation->addValidationResult('Tags', '@'.t('ValidateTag', 'Tags cannot contain commas.')); + } + if (count($tags) > $maxTags) { + $sender->Validation->addValidationResult('Tags', '@'.sprintf(t('You can only specify up to %s tags.'), $maxTags)); + } + } + } + + /** + * Save tags when saving a discussion. + * + * @param DiscussionModel $sender + */ + public function discussionModel_afterSaveDiscussion_handler($sender) { + $formPostValues = val('FormPostValues', $sender->EventArguments, []); + $discussionID = val('DiscussionID', $sender->EventArguments, 0); + $categoryID = valr('Fields.CategoryID', $sender->EventArguments, 0); + $rawFormTags = val('Tags', $formPostValues, ''); + $formTags = TagModel::splitTags($rawFormTags); + + // If we're associating with categories + $categorySearch = c('Vanilla.Tagging.CategorySearch', false); + if ($categorySearch) { + $categoryID = val('CategoryID', $formPostValues, false); + } + + // Let plugins have their information getting saved. + $types = ['']; + + // We fire as TaggingPlugin since this code was taken from the old TaggingPlugin and we do not + // want to break any hooks + Gdn::pluginManager()->fireAs('TaggingPlugin')->fireEvent('SaveDiscussion', [ + 'Data' => $formPostValues, + 'Tags' => &$formTags, + 'Types' => &$types, + 'CategoryID' => $categoryID, + ]); + + // Save the tags to the db. + TagModel::instance()->saveDiscussion($discussionID, $formTags, $types, $categoryID); + } + + /** + * Handle tag association deletion when a discussion is deleted. + * + * @param $sender + * @throws Exception + */ + public function discussionModel_deleteDiscussion_handler($sender) { + // Get discussionID that is being deleted + $discussionID = $sender->EventArguments['DiscussionID']; + + // Get List of tags to reduce count for + $tagDataSet = Gdn::sql()->select('TagID') + ->from('TagDiscussion') + ->where('DiscussionID', $discussionID) + ->get()->resultArray(); + + $removedTagIDs = array_column($tagDataSet, 'TagID'); + + // Check if there are even any tags to delete + if (count($removedTagIDs) > 0) { + // Step 1: Reduce count + Gdn::sql() + ->update('Tag') + ->set('CountDiscussions', 'CountDiscussions - 1', false) + ->whereIn('TagID', $removedTagIDs) + ->put(); + + // Step 2: Delete mapping data between discussion and tag (tagdiscussion table) + $sender->SQL->where('DiscussionID', $discussionID)->delete('TagDiscussion'); + } + } + + /** + * Add the tag input to the discussion form. + * + * @param Gdn_Controller $Sender + */ + public function postController_afterDiscussionFormOptions_handler($Sender) { + if (!c('Tagging.Discussions.Enabled')) { + return; + } + + if (in_array($Sender->RequestMethod, ['discussion', 'editdiscussion', 'question', 'idea'])) { + // Setup, get most popular tags + $TagModel = TagModel::instance(); + $Tags = $TagModel->getWhere(['Type' => array_keys($TagModel->defaultTypes())], 'CountDiscussions', 'desc', c('Vanilla.Tagging.ShowLimit', 50))->result(DATASET_TYPE_ARRAY); + $TagsHtml = (count($Tags)) ? '' : t('No tags have been created yet.'); + $Tags = Gdn_DataSet::index($Tags, 'FullName'); + ksort($Tags); + + // The tags must be fetched. + if ($Sender->Request->isPostBack()) { + $tag_ids = TagModel::splitTags($Sender->Form->getFormValue('Tags')); + $tags = TagModel::instance()->getWhere(['TagID' => $tag_ids])->resultArray(); + $tags = array_column($tags, 'TagID', 'FullName'); + } else { + // The tags should be set on the data. + $tags = array_column($Sender->data('Tags', []), 'FullName', 'TagID'); + $xtags = $Sender->data('XTags', []); + foreach (TagModel::instance()->defaultTypes() as $key => $row) { + if (isset($xtags[$key])) { + $xtags2 = array_column($xtags[$key], 'FullName', 'TagID'); + foreach ($xtags2 as $id => $name) { + $tags[$id] = $name; + } + } + } + } + + echo '
'; + + // Tag text box + echo $Sender->Form->label('Tags', 'Tags'); + echo $Sender->Form->textBox('Tags', ['data-tags' => json_encode($tags)]); + + // Available tags + echo wrap(anchor(t('Show popular tags'), '#'), 'span', ['class' => 'ShowTags']); + foreach ($Tags as $Tag) { + $TagsHtml .= anchor(htmlspecialchars($Tag['FullName']), '#', 'AvailableTag', ['data-name' => $Tag['Name'], 'data-id' => $Tag['TagID']]).' '; + } + echo wrap($TagsHtml, 'div', ['class' => 'Hidden AvailableTags']); + + echo '
'; + } + } + + /** + * Add javascript to the post/edit discussion page so that tagging autocomplete works. + * + * @param PostController $Sender + */ + public function postController_render_before($Sender) { + $Sender->addDefinition('TaggingAdd', Gdn::session()->checkPermission('Vanilla.Tagging.Add')); + $Sender->addDefinition('TaggingSearchUrl', Gdn::request()->url('tags/search')); + $Sender->addDefinition('MaxTagsAllowed', c('Vanilla.Tagging.Max', 5)); + + // Make sure that detailed tag data is available to the form. + $TagModel = TagModel::instance(); + + $DiscussionID = $Sender->data('Discussion.DiscussionID'); + + if ($DiscussionID) { + $Tags = $TagModel->getDiscussionTags($DiscussionID, TagModel::IX_EXTENDED); + $Sender->setData($Tags); + } elseif (!$Sender->Request->isPostBack() && $tagString = $Sender->Request->get('tags')) { + $tags = explodeTrim(',', $tagString); + $types = array_column(TagModel::instance()->defaultTypes(), 'key'); + + // Look up the tags by name. + $tagData = Gdn::sql()->getWhere( + 'Tag', + ['Name' => $tags, 'Type' => $types] + )->resultArray(); + + // Add any missing tags. + $tagNames = array_change_key_case(array_column($tagData, 'Name', 'Name')); + foreach ($tags as $tag) { + $tagKey = strtolower($tag); + if (!isset($tagNames[$tagKey])) { + $tagData[] = ['TagID' => $tag, 'Name' => $tagKey, 'FullName' => $tag, 'Type' => '']; + } + } + + $Sender->setData('Tags', $tagData); + } + } + + /** + * Provide default permissions for roles, based on the value in their Type column. + * + * @param PermissionModel $sender Instance of permission model that fired the event + */ + public function permissionModel_defaultPermissions_handler($sender) { + // Guest defaults + $sender->addDefault( + RoleModel::TYPE_GUEST, + [ + 'Vanilla.Discussions.View' => 0 + ] + ); + $sender->addDefault( + RoleModel::TYPE_GUEST, + [ + 'Vanilla.Discussions.View' => 0 + ], + 'Category', + -1 + ); + + // Unconfirmed defaults + $sender->addDefault( + RoleModel::TYPE_UNCONFIRMED, + [ + 'Vanilla.Discussions.View' => 0 + ] + ); + $sender->addDefault( + RoleModel::TYPE_UNCONFIRMED, + [ + 'Vanilla.Discussions.View' => 0 + ], + 'Category', + -1 + ); + + // Applicant defaults + $sender->addDefault( + RoleModel::TYPE_APPLICANT, + [ + 'Vanilla.Discussions.View' => 0 + ] + ); + $sender->addDefault( + RoleModel::TYPE_APPLICANT, + [ + 'Vanilla.Discussions.View' => 0 + ], + 'Category', + -1 + ); + + // Member defaults + $sender->addDefault( + RoleModel::TYPE_MEMBER, + [ + 'Vanilla.Discussions.Add' => 1, + 'Vanilla.Discussions.View' => 1, + 'Vanilla.Comments.Add' => 1 + ] + ); + $sender->addDefault( + RoleModel::TYPE_MEMBER, + [ + 'Vanilla.Discussions.Add' => 1, + 'Vanilla.Discussions.View' => 1, + 'Vanilla.Comments.Add' => 1 + ], + 'Category', + -1 + ); + + // Moderator defaults + $sender->addDefault( + RoleModel::TYPE_MODERATOR, + [ + 'Vanilla.Discussions.Add' => 1, + 'Vanilla.Discussions.Edit' => 1, + 'Vanilla.Discussions.Announce' => 1, + 'Vanilla.Discussions.Sink' => 1, + 'Vanilla.Discussions.Close' => 1, + 'Vanilla.Discussions.Delete' => 1, + 'Vanilla.Discussions.View' => 1, + 'Vanilla.Comments.Add' => 1, + 'Vanilla.Comments.Edit' => 1, + 'Vanilla.Comments.Delete' => 1 + ] + ); + $sender->addDefault( + RoleModel::TYPE_MODERATOR, + [ + 'Vanilla.Discussions.Add' => 1, + 'Vanilla.Discussions.Edit' => 1, + 'Vanilla.Discussions.Announce' => 1, + 'Vanilla.Discussions.Sink' => 1, + 'Vanilla.Discussions.Close' => 1, + 'Vanilla.Discussions.Delete' => 1, + 'Vanilla.Discussions.View' => 1, + 'Vanilla.Comments.Add' => 1, + 'Vanilla.Comments.Edit' => 1, + 'Vanilla.Comments.Delete' => 1 + ], + 'Category', + -1 + ); + + // Administrator defaults + $sender->addDefault( + RoleModel::TYPE_ADMINISTRATOR, + [ + 'Vanilla.Discussions.Add' => 1, + 'Vanilla.Discussions.Edit' => 1, + 'Vanilla.Discussions.Announce' => 1, + 'Vanilla.Discussions.Sink' => 1, + 'Vanilla.Discussions.Close' => 1, + 'Vanilla.Discussions.Delete' => 1, + 'Vanilla.Discussions.View' => 1, + 'Vanilla.Comments.Add' => 1, + 'Vanilla.Comments.Edit' => 1, + 'Vanilla.Comments.Delete' => 1 + ] + ); + $sender->addDefault( + RoleModel::TYPE_ADMINISTRATOR, + [ + 'Vanilla.Discussions.Add' => 1, + 'Vanilla.Discussions.Edit' => 1, + 'Vanilla.Discussions.Announce' => 1, + 'Vanilla.Discussions.Sink' => 1, + 'Vanilla.Discussions.Close' => 1, + 'Vanilla.Discussions.Delete' => 1, + 'Vanilla.Discussions.View' => 1, + 'Vanilla.Comments.Add' => 1, + 'Vanilla.Comments.Edit' => 1, + 'Vanilla.Comments.Delete' => 1 + ], + 'Category', + -1 + ); + + // Topcoder defaults + $sender->addDefault( + RoleModel::TYPE_TOPCODER, + [ ], + 'Category', + -1 + ); + + } + + /** + * Remove Vanilla data when deleting a user. + * + * @since 2.0.0 + * @package Vanilla + * + * @param UserModel $sender UserModel. + */ + public function userModel_beforeDeleteUser_handler($sender) { + $userID = val('UserID', $sender->EventArguments); + $options = val('Options', $sender->EventArguments, []); + $options = is_array($options) ? $options : []; + $content = &$sender->EventArguments['Content']; + + $this->deleteUserData($userID, $options, $content); + } + + /** + * Check whether a user has access to view discussions in a particular category. + * + * @since 2.0.18 + * @example $UserModel->getCategoryViewPermission($userID, $categoryID). + * + * @param $sender UserModel. + * @return bool Whether user has permission. + */ + public function userModel_getCategoryViewPermission_create($sender) { + $userID = val(0, $sender->EventArguments, ''); + $categoryID = val(1, $sender->EventArguments, ''); + $permission = val(2, $sender->EventArguments, 'Vanilla.Discussions.View'); + if ($userID && $categoryID) { + $category = CategoryModel::categories($categoryID); + if ($category) { + $permissionCategoryID = $category['PermissionCategoryID']; + } else { + $permissionCategoryID = -1; + } + + $options = ['ForeignID' => $permissionCategoryID]; + $result = Gdn::userModel()->checkPermission($userID, $permission, $options); + return $result; + } + return false; + } + + /** + * Add CSS assets to front end. + * + * @param AssetModel $sender + */ + public function assetModel_afterGetCssFiles_handler($sender) { + if (!inSection('Dashboard')) { + $sender->addCssFile('tag.css', 'vanilla', ['Sort' => 800]); + } + } + + /** + * Adds 'Discussion' item to menu. + * + * 'base_render_before' will trigger before every pageload across apps. + * If you abuse this hook, Tim will throw a Coke can at your head. + * + * @since 2.0.0 + * @package Vanilla + * + * @param Gdn_Controller $sender The sending controller object. + */ + public function base_render_before($sender) { + if ($sender->Menu) { + $sender->Menu->addLink('Discussions', t('Discussions'), '/discussions', false, ['Standard' => true]); + } + + if (!inSection('Dashboard')) { + // Spoilers assets + $sender->addJsFile('spoilers.js', 'vanilla'); + $sender->addCssFile('spoilers.css', 'vanilla'); + $sender->addDefinition('Spoiler', t('Spoiler')); + $sender->addDefinition('show', t('show')); + $sender->addDefinition('hide', t('hide')); + } + + // Add user's viewable roles to gdn.meta if user is logged in. + if (!$sender->addDefinition('Roles')) { + if (Gdn::session()->isValid()) { + $roleModel = new RoleModel(); + Gdn::controller()->addDefinition("Roles", $roleModel->getPublicUserRoles(Gdn::session()->UserID, "Name")); + } + } + + // Tagging BEGIN + // Set breadcrumbs where relevant + if (null !== $sender->data('Tag', null) && null !== $sender->data('Tags')) { + $parentTag = []; + $currentTag = $sender->data('Tag'); + $currentTags = $sender->data('Tags'); + + $parentTagID = ($currentTag['ParentTagID']) + ? $currentTag['ParentTagID'] + : ''; + + foreach ($currentTags as $tag) { + foreach ($tag as $subTag) { + if ($subTag['TagID'] == $parentTagID) { + $parentTag = $subTag; + } + } + } + + $breadcrumbs = []; + + if (is_array($parentTag) && count(array_filter($parentTag))) { + $breadcrumbs[] = ['Name' => $parentTag['FullName'], 'Url' => tagUrl($parentTag, '', '/')]; + } + + if (is_array($currentTag) && count(array_filter($currentTag))) { + $breadcrumbs[] = ['Name' => $currentTag['FullName'], 'Url' => tagUrl($currentTag, '', '/')]; + } + + if (count($breadcrumbs)) { + // Rebuild breadcrumbs in discussions when there is a child, as the + // parent must come before it. + $sender->setData('Breadcrumbs', $breadcrumbs); + } + } + + if (null !== $sender->data('Announcements', null)) { + TagModel::instance()->joinTags($sender->Data['Announcements']); + } + + if (null !== $sender->data('Discussions', null)) { + TagModel::instance()->joinTags($sender->Data['Discussions']); + } + + $sender->addJsFile('tagging.js', 'vanilla'); + $sender->addJsFile('jquery.tokeninput.js'); + // Tagging END + } + + /** + * Adds 'Discussions' tab to profiles and adds CSS & JS files to their head. + * + * @since 2.0.0 + * @package Vanilla + * + * @param ProfileController $sender + */ + public function profileController_addProfileTabs_handler($sender) { + if (is_object($sender->User) && $sender->User->UserID > 0) { + $userID = $sender->User->UserID; + // Add the discussion tab + $discussionsLabel = sprite('SpDiscussions').' '.t('Discussions'); + $commentsLabel = sprite('SpComments').' '.t('Comments'); + if (c('Vanilla.Profile.ShowCounts', true)) { + $discussionsCount = getValueR('User.CountDiscussions', $sender, null); + $commentsCount = getValueR('User.CountComments', $sender, null); + + if (!is_null($discussionsCount) && !empty($discussionsCount)) { + $discussionsLabel .= + '' . + countString(bigPlural($discussionsCount, '%s discussion'), "/profile/count/discussions?userid=$userID") + . ''; + } + if (!is_null($commentsCount) && !empty($commentsCount)) { + $commentsLabel .= + '' . + countString(bigPlural($commentsCount, '%s comment'), "/profile/count/comments?userid=$userID") . + ''; + } + } + $sender->addProfileTab(t('Discussions'), userUrl($sender->User, '', 'discussions'), 'Discussions', $discussionsLabel); + $sender->addProfileTab(t('Comments'), userUrl($sender->User, '', 'comments'), 'Comments', $commentsLabel); + // Add the discussion tab's CSS and Javascript. + $sender->addJsFile('jquery.gardenmorepager.js'); + $sender->addJsFile('discussions.js', 'vanilla'); + } + } + + /** + * Adds email notification options to profiles. + * + * @since 2.0.0 + * @package Vanilla + * + * @param ProfileController $sender + */ + public function profileController_afterPreferencesDefined_handler($sender) { + $sender->Preferences['Notifications']['Email.DiscussionComment'] = t('Notify me when people comment on my discussions.'); + $sender->Preferences['Notifications']['Email.BookmarkComment'] = t('Notify me when people comment on my bookmarked discussions.'); + $sender->Preferences['Notifications']['Email.Mention'] = t('Notify me when people mention me.'); + $sender->Preferences['Notifications']['Email.ParticipateComment'] = t('Notify me when people comment on discussions I\'ve participated in.'); + + $sender->Preferences['Notifications']['Popup.DiscussionComment'] = t('Notify me when people comment on my discussions.'); + $sender->Preferences['Notifications']['Popup.BookmarkComment'] = t('Notify me when people comment on my bookmarked discussions.'); + $sender->Preferences['Notifications']['Popup.Mention'] = t('Notify me when people mention me.'); + $sender->Preferences['Notifications']['Popup.ParticipateComment'] = t('Notify me when people comment on discussions I\'ve participated in.'); + + if (Gdn::session()->checkPermission('Garden.AdvancedNotifications.Allow')) { + $postBack = $sender->Form->authenticatedPostBack(); + $set = []; + + // Add the category definitions to for the view to pick up. + $doHeadings = c('Vanilla.Categories.DoHeadings'); + // Grab all of the categories. + $categories = []; + $prefixes = ['Email.NewDiscussion', 'Popup.NewDiscussion', 'Email.NewComment', 'Popup.NewComment']; + foreach (CategoryModel::categories() as $category) { + if (!$category['PermsDiscussionsView'] || $category['Depth'] <= 0 || $category['Depth'] > 2 || $category['Archived']) { + continue; + } + + $category['Heading'] = ($doHeadings && $category['Depth'] <= 1); + $categories[] = $category; + + if ($postBack) { + foreach ($prefixes as $prefix) { + $fieldName = "$prefix.{$category['CategoryID']}"; + $value = $sender->Form->getFormValue($fieldName, null); + if (!$value) { + $value = null; + } + $set[$fieldName] = $value; + } + } + } + $sender->setData('CategoryNotifications', $categories); + if ($postBack) { + UserModel::setMeta($sender->User->UserID, $set, 'Preferences.'); + } + } + } + + /** + * Add the advanced notifications view to profiles. + * + * @param ProfileController $Sender + */ + public function profileController_customNotificationPreferences_handler($Sender) { + if (Gdn::session()->checkPermission('Garden.AdvancedNotifications.Allow')) { + include $Sender->fetchViewLocation('notificationpreferences', 'vanillasettings', 'vanilla'); + } + } + + /** + * Add the discussion search to the search. + * + * @since 2.0.0 + * @package Vanilla + * + * @param object $sender SearchModel + */ + public function searchModel_search_handler($sender) { + $searchModel = new VanillaSearchModel(); + $searchModel->search($sender); + } + + /** + * @param NavModule $sender + */ + public function siteNavModule_init_handler($sender) { + // Grab the default route so that we don't add a link to it twice. + $home = trim(val('Destination', Gdn::router()->getRoute('DefaultController')), '/'); + + // Add the site discussion links. + $sender->addLinkIf($home !== 'categories', t('All Categories', 'Categories'), '/categories', 'main.categories', '', 1, ['icon' => 'th-list']); + $sender->addLinkIf($home !== 'discussions', t('Recent Discussions'), '/discussions', 'main.discussions', '', 1, ['icon' => 'discussion']); + $sender->addGroup(t('Favorites'), 'favorites', '', 3); + + if (Gdn::session()->isValid()) { + $sender->addLink(t('My Bookmarks'), '/discussions/bookmarked', 'favorites.bookmarks', '', [], ['icon' => 'star', 'badge' => Gdn::session()->User->CountBookmarks]); + $sender->addLink(t('My Discussions'), '/discussions/mine', 'favorites.discussions', '', [], ['icon' => 'discussion', 'badge' => Gdn::session()->User->CountDiscussions]); + $sender->addLink(t('Drafts'), '/drafts', 'favorites.drafts', '', [], ['icon' => 'compose', 'badge' => Gdn::session()->User->CountDrafts]); + } + + $user = Gdn::controller()->data('Profile'); + if (!$user) { + return; + } + $sender->addGroupToSection('Profile', t('Posts'), 'posts'); + $sender->addLinkToSection('Profile', t('Discussions'), userUrl($user, '', 'discussions'), 'posts.discussions', '', [], ['icon' => 'discussion', 'badge' => val('CountDiscussions', $user)]); + $sender->addLinkToSection('Profile', t('Comments'), userUrl($user, '', 'comments'), 'posts.comments', '', [], ['icon' => 'comment', 'badge' => val('CountComments', $user)]); + } + + /** + * Creates virtual 'Comments' method in ProfileController. + * + * @since 2.0.0 + * @package Vanilla + * + * @param ProfileController $sender ProfileController. + */ + public function profileController_comments_create($sender, $userReference = '', $username = '', $page = '', $userID = '') { + $sender->permission('Garden.Profiles.View'); + + $sender->editMode(false); + $view = $sender->View; + + // Tell the ProfileController what tab to load + $sender->getUserInfo($userReference, $username, $userID); + $sender->_setBreadcrumbs(t('Comments'), userUrl($sender->User, '', 'comments')); + $sender->setTabView('Comments', 'profile', 'Discussion', 'Vanilla'); + + $pageSize = c('Vanilla.Discussions.PerPage', 30); + list($offset, $limit) = offsetLimit($page, $pageSize); + + $commentModel = new CommentModel(); + $comments = $commentModel->getByUser2($sender->User->UserID, $limit, $offset, $sender->Request->get('lid')); + $totalRecords = $offset + $commentModel->LastCommentCount + 1; + + // Build a pager + $pagerFactory = new Gdn_PagerFactory(); + $sender->Pager = $pagerFactory->getPager('MorePager', $sender); + $sender->Pager->MoreCode = 'More Comments'; + $sender->Pager->LessCode = 'Newer Comments'; + $sender->Pager->ClientID = 'Pager'; + $sender->Pager->configure( + $offset, + $limit, + $totalRecords, + userUrl($sender->User, '', 'comments').'?page={Page}' //?lid='.$CommentModel->LastCommentID + ); + + // Deliver JSON data if necessary + if ($sender->deliveryType() != DELIVERY_TYPE_ALL && $offset > 0) { + $sender->setJson('LessRow', $sender->Pager->toString('less')); + $sender->setJson('MoreRow', $sender->Pager->toString('more')); + $sender->View = 'profilecomments'; + } + $sender->setData('Comments', $comments); + $sender->setData('UnfilteredCommentsCount', $commentModel->LastCommentCount); + + // Set the HandlerType back to normal on the profilecontroller so that it fetches it's own views + $sender->HandlerType = HANDLER_TYPE_NORMAL; + + // Do not show discussion options + $sender->ShowOptions = false; + + if ($sender->Head) { + $sender->Head->addTag('meta', ['name' => 'robots', 'content' => 'noindex,noarchive']); + } + + // Render the ProfileController + $sender->render(); + } + + /** + * Creates virtual 'Discussions' method in ProfileController. + * + * @since 2.0.0 + * @package Vanilla + * + * @param ProfileController $sender ProfileController. + */ + public function profileController_discussions_create($sender, $userReference = '', $username = '', $page = '', $userID = '') { + $sender->permission('Garden.Profiles.View'); + + $sender->editMode(false); + + // Tell the ProfileController what tab to load + $sender->getUserInfo($userReference, $username, $userID); + $sender->_setBreadcrumbs(t('Discussions'), userUrl($sender->User, '', 'discussions')); + $sender->setTabView('Discussions', 'Profile', 'Discussions', 'Vanilla'); + $sender->CountCommentsPerPage = c('Vanilla.Comments.PerPage', 30); + + list($offset, $limit) = offsetLimit($page, c('Vanilla.Discussions.PerPage', 30)); + + $discussionModel = new DiscussionModel(); + $discussions = $discussionModel->getByUser($sender->User->UserID, $limit, $offset, false, Gdn::session()->UserID); + $countDiscussions = $offset + $discussionModel->LastDiscussionCount + 1; + + $sender->setData('UnfilteredDiscussionsCount', $discussionModel->LastDiscussionCount); + $sender->DiscussionData = $sender->setData('Discussions', $discussions); + + // Build a pager + $pagerFactory = new Gdn_PagerFactory(); + $sender->Pager = $pagerFactory->getPager('MorePager', $sender); + $sender->Pager->MoreCode = 'More Discussions'; + $sender->Pager->LessCode = 'Newer Discussions'; + $sender->Pager->ClientID = 'Pager'; + $sender->Pager->configure( + $offset, + $limit, + $countDiscussions, + userUrl($sender->User, '', 'discussions').'?page={Page}' + ); + + // Deliver JSON data if necessary + if ($sender->deliveryType() != DELIVERY_TYPE_ALL && $offset > 0) { + $sender->setJson('LessRow', $sender->Pager->toString('less')); + $sender->setJson('MoreRow', $sender->Pager->toString('more')); + $sender->View = 'discussions'; + } + + // Set the HandlerType back to normal on the profilecontroller so that it fetches it's own views + $sender->HandlerType = HANDLER_TYPE_NORMAL; + + // Do not show discussion options + $sender->ShowOptions = false; + + if ($sender->Head) { + // These pages offer only duplicate content to search engines and are a bit slow. + $sender->Head->addTag('meta', ['name' => 'robots', 'content' => 'noindex,noarchive']); + } + + // Render the ProfileController + $sender->render(); + } + + /** + * Makes sure forum administrators can see the dashboard admin pages. + * + * @since 2.0.0 + * @package Vanilla + * + * @param object $sender SettingsController. + */ + public function settingsController_defineAdminPermissions_handler($sender) { + if (isset($sender->RequiredAdminPermissions)) { + $sender->RequiredAdminPermissions[] = 'Garden.Settings.Manage'; + } + } + + /** + * Discussion view counter. + * + * @param $sender + * @param $args + */ + public function gdn_statistics_tick_handler($sender, $args) { + $path = Gdn::request()->post('Path'); + $args = Gdn::request()->post('Args'); + parse_str($args, $args); + $resolvedPath = trim(Gdn::request()->post('ResolvedPath'), '/'); + $resolvedArgs = Gdn::request()->post('ResolvedArgs'); + $discussionID = null; + $discussionModel = new DiscussionModel(); + + // Comment permalink + if ($resolvedPath == 'vanilla/discussion/comment') { + $commentID = val('CommentID', $resolvedArgs); + $commentModel = new CommentModel(); + $comment = $commentModel->getID($commentID); + $discussionID = val('DiscussionID', $comment); + } // Discussion link + elseif ($resolvedPath == 'vanilla/discussion/index') { + $discussionID = val('DiscussionID', $resolvedArgs, null); + } // Embedded discussion + elseif ($resolvedPath == 'vanilla/discussion/embed') { + $foreignID = val('vanilla_identifier', $args); + if ($foreignID) { + // This will be hit a lot so let's try caching it... + $key = "DiscussionID.ForeignID.page.$foreignID"; + $discussionID = Gdn::cache()->get($key); + if (!$discussionID) { + $discussion = $discussionModel->getForeignID($foreignID, 'page'); + $discussionID = val('DiscussionID', $discussion); + Gdn::cache()->store($key, $discussionID, [Gdn_Cache::FEATURE_EXPIRY, 1800]); + } + } + } + + if ($discussionID) { + $discussionModel->addView($discussionID); + } + } + + /** + * Adds items to Dashboard menu. + * + * @since 2.0.0 + * @package Vanilla + * + * @param DashboardNavModule $sender + */ + public function dashboardNavModule_init_handler($sender) { + $sort = -1; // Ensure these items go before any plugin items. + + $sender->addLinkIf('Garden.Community.Manage', t('Categories'), '/vanilla/settings/categories', 'forum.manage-categories', 'nav-manage-categories', $sort) + ->addLinkIf('Garden.Settings.Manage', t('Posting'), '/vanilla/settings/posting', 'forum.posting', 'nav-forum-posting', $sort) + ->addLinkIf(c('Vanilla.Archive.Date', false) && Gdn::session()->checkPermission('Garden.Settings.Manage'), t('Archive Discussions'), '/vanilla/settings/archive', 'forum.archive', 'nav-forum-archive', $sort) + ->addLinkIf('Garden.Settings.Manage', t('Embedding'), 'embed/forum', 'site-settings.embed-site', 'nav-embed nav-embed-site', $sort) + ->addLinkToSectionIf('Garden.Settings.Manage', 'Moderation', t('Flood Control'), '/vanilla/settings/floodcontrol', 'moderation.flood-control', 'nav-flood-control', $sort); + } + + /** + * Handle post-restore operations from the log table. + * + * @param LogModel $sender + * @param array $args + */ + public function logModel_afterRestore_handler($sender, $args) { + $recordType = valr('Log.RecordType', $args); + $recordUserID = valr('Log.RecordUserID', $args); + + if ($recordUserID === false) { + return; + } + + switch ($recordType) { + case 'Comment': + $commentModel = new CommentModel(); + $commentModel->updateUser($recordUserID, true); + break; + case 'Discussion': + $discussionModel = new DiscussionModel(); + $discussionModel->updateUserDiscussionCount($recordUserID, true); + break; + } + } + + /** + * @deprecated Request /tags/search instead + */ + public function pluginController_tagsearch_create() { + $query = http_build_query(Gdn::request()->getQuery()); + redirectTo(url('/tags/search'.($query ? '?'.$query : null)), 301); + } + + /** + * Hook in before a discussion is rendered and display any messages. + * + * @param mixed DiscussionController $sender + * @param array array $args + */ + public function discussionController_beforeDiscussionDisplay_handler($sender, array $args) { + if (!($sender instanceof DiscussionController)) { + return; + } + + $messages = $sender->getMessages(); + foreach ($messages as $message) { + echo $message; + } + } + + /** + * Automatically executed when application is enabled. + * + * @since 2.0.0 + * @package Vanilla + */ + public function setup() { + $Database = Gdn::database(); + $Config = Gdn::factory(Gdn::AliasConfig); + $Drop = false; + + // Call structure.php to update database + $Validation = new Gdn_Validation(); // Needed by structure.php to validate permission names + include(PATH_APPLICATIONS.DS.'vanilla'.DS.'settings'.DS.'structure.php'); + + saveToConfig('Routes.DefaultController', 'discussions'); + } +}