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');
+ }
+}