diff --git a/includes/AutoLoader.php b/includes/AutoLoader.php index 09eea7d..9c671e1 100644 --- a/includes/AutoLoader.php +++ b/includes/AutoLoader.php @@ -254,6 +254,7 @@ $wgAutoloadLocalClasses = array( 'UserArrayFromResult' => 'includes/UserArray.php', 'UserBlockedError' => 'includes/Exception.php', 'UserMailer' => 'includes/UserMailer.php', + 'UserMessage' => 'includes/UserMessage.php', 'UserRightsProxy' => 'includes/UserRightsProxy.php', 'ViewCountUpdate' => 'includes/ViewCountUpdate.php', 'WantedQueryPage' => 'includes/QueryPage.php', @@ -635,6 +636,7 @@ $wgAutoloadLocalClasses = array( 'JSParser' => 'includes/libs/jsminplus.php', # includes/logging + 'AuthLogFormatter' => 'includes/logging/LogFormatter.php', 'DatabaseLogEntry' => 'includes/logging/LogEntry.php', 'DeleteLogFormatter' => 'includes/logging/LogFormatter.php', 'LegacyLogFormatter' => 'includes/logging/LogFormatter.php', diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index c2606ce..19bbc79 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -3254,6 +3254,52 @@ $wgPasswordResetRoutes = array( ); /** + * Turns on or off logging of authentication attempts. + */ +$wgAuthLogging = true; + +/** + * Whether to log successful logins in addition to failed logins. + */ +$wgAuthLoggingStoreValid = true; + +/** + * Whether to store IP addresses with all login attempts. + */ +$wgAuthLoggingStoreIP = true; + +/** + * Whether to display message notifications to the user any time an invalid login + * attempt is made. Similar to talk page messages. + */ +$wgAuthLoggingNotifications = true; + +/** + * If either $wgAuthLoggingNotifications is set to false or if a user does not log + * in for an extended period of time, set the conditions for which the user will be + * sent an email warning of invalid logins. If there are more than $threshold invalid + * logins in $period amount of time, the user will be emailed. Set to false to disable. + */ +$wgAuthLoggingEmail = array( 'period' => 72, 'threshold' => 10 ); + +/** + * Whether to allow the user to customize his/her notifications and email settings for + * invalid logins. Strict sysadmins can set this to false to force users to be notified + * of invalid logins. Note that users can only turn notifications and emails on/off, they + * cannot customize the options in $wgAuthLoggingEmail. + */ +$wgAuthLoggingUserPref = true; + +/** + * Grace period in seconds for authentication logging. If an IP address + * makes a failed login attempt, but logs in successfully before this + * grace period ends, the user will not be notified. This stops the site + * from warning users of their own accidental invalid logins. + */ +$wgAuthLoggingGracePeriod = 300; + + +/** * Maximum number of Unicode characters in signature */ $wgMaxSigChars = 255; @@ -3567,6 +3613,7 @@ $wgGroupPermissions['user']['reupload-shared'] = true; $wgGroupPermissions['user']['minoredit'] = true; $wgGroupPermissions['user']['purge'] = true; // can use ?action=purge without clicking "ok" $wgGroupPermissions['user']['sendemail'] = true; +$wgGroupPermissions['user']['authlog-own'] = true; // Implicit group for accounts that pass $wgAutoConfirmAge $wgGroupPermissions['autoconfirmed']['autoconfirmed'] = true; @@ -3623,6 +3670,7 @@ $wgGroupPermissions['sysop']['suppressredirect'] = true; // Permission to change users' group assignments $wgGroupPermissions['bureaucrat']['userrights'] = true; $wgGroupPermissions['bureaucrat']['noratelimit'] = true; +$wgGroupPermissions['bureaucrat']['authlog'] = true; // Permission to change users' groups assignments across wikis #$wgGroupPermissions['bureaucrat']['userrights-interwiki'] = true; // Permission to export pages including linked pages regardless of $wgExportMaxLinkDepth @@ -5145,6 +5193,7 @@ $wgLogTypes = array( 'patrol', 'merge', 'suppress', + 'auth' ); /** @@ -5155,7 +5204,8 @@ $wgLogTypes = array( * Format: logtype => permissiontype */ $wgLogRestrictions = array( - 'suppress' => 'suppressionlog' + 'suppress' => 'suppressionlog', + 'auth' => array( 'all' => 'authlog', 'own' => 'authlog-own' ) ); /** @@ -5203,6 +5253,7 @@ $wgLogNames = array( 'patrol' => 'patrol-log-page', 'merge' => 'mergelog', 'suppress' => 'suppressionlog', + 'auth' => 'authlogpage' ); /** @@ -5226,6 +5277,7 @@ $wgLogHeaders = array( 'patrol' => 'patrol-log-header', 'merge' => 'mergelogpagetext', 'suppress' => 'suppressionlogtext', + 'auth' => 'authlogtext' ); /** @@ -5269,6 +5321,7 @@ $wgLogActionsHandlers = array( 'suppress/event' => 'DeleteLogFormatter', 'suppress/delete' => 'DeleteLogFormatter', 'patrol/patrol' => 'PatrolLogFormatter', + 'auth/*' => 'AuthLogFormatter' ); /** diff --git a/includes/Preferences.php b/includes/Preferences.php index bf63d65..8b77263 100644 --- a/includes/Preferences.php +++ b/includes/Preferences.php @@ -156,7 +156,8 @@ class Preferences { global $wgAuth, $wgContLang, $wgParser, $wgCookieExpiration, $wgLanguageCode, $wgDisableTitleConversion, $wgDisableLangConversion, $wgMaxSigChars, $wgEnableEmail, $wgEmailConfirmToEdit, $wgEnableUserEmail, $wgEmailAuthentication, - $wgEnotifWatchlist, $wgEnotifUserTalk, $wgEnotifRevealEditorAddress; + $wgEnotifWatchlist, $wgEnotifUserTalk, $wgEnotifRevealEditorAddress, + $wgAuthLoggingNotifications, $wgAuthLoggingUserPref, $wgAuthLoggingEmail; ## User info ##################################### // Information panel @@ -270,6 +271,15 @@ class Preferences { 'section' => 'personal/info', ); } + + if( $wgAuthLoggingNotifications ) { + $defaultPreferences['invalidloginnotification'] = array( + 'type' => 'toggle', + 'label' => $context->msg( 'prefs-authnotify' )->text(), + 'section' => 'personal/info', + 'disabled' => !$wgAuthLoggingUserPref + ); + } // Language $languages = Language::fetchLanguageNames( null, 'mw' ); @@ -482,6 +492,16 @@ class Preferences { ); } } + + if( $wgAuthLoggingEmail ) { + $defaultPreferences['invalidloginemail'] = array( + 'type' => 'toggle', + 'section' => 'personal/email', + 'label' => $context->msg( 'prefs-authemail' )->numParams( + $wgAuthLoggingEmail['threshold'], $wgAuthLoggingEmail['period'] )->text(), + 'disabled' => $disableEmailPrefs || !$wgAuthLoggingUserPref + ); + } } } diff --git a/includes/Skin.php b/includes/Skin.php index 8d47b83..5ee66da 100644 --- a/includes/Skin.php +++ b/includes/Skin.php @@ -1336,55 +1336,48 @@ abstract class Skin extends ContextSource { * @return MediaWiki message or if no new talk page messages, nothing */ function getNewtalks() { - $out = $this->getOutput(); - - $newtalks = $this->getUser()->getNewMessageLinks(); - $ntl = ''; - - if ( count( $newtalks ) == 1 && $newtalks[0]['wiki'] === wfWikiID() ) { - $userTitle = $this->getUser()->getUserPage(); - $userTalkTitle = $userTitle->getTalkPage(); - - if ( !$userTalkTitle->equals( $out->getTitle() ) ) { - $newMessagesLink = Linker::linkKnown( - $userTalkTitle, - $this->msg( 'newmessageslink' )->escaped(), - array(), - array( 'redirect' => 'no' ) - ); - - $newMessagesDiffLink = Linker::linkKnown( - $userTalkTitle, - $this->msg( 'newmessagesdifflink' )->escaped(), - array(), - array( 'diff' => 'cur' ) - ); - - $ntl = $this->msg( - 'youhavenewmessages', - $newMessagesLink, - $newMessagesDiffLink - )->text(); - # Disable Squid cache - $out->setSquidMaxage( 0 ); + global $wgAuthLoggingUserPref, $wgAuthLogging, $wgAuthLoggingNotifications; + $user = $this->getUser(); + $title = $this->getOutput()->getTitle(); + $messages = UserMessage::getUserMessages( $user ); + + $ntls = array(); + foreach( $messages as $message ) { + if( $message->getType() == UserMessage::MSG_TALK ) { + $page = $user->getUserPage()->getTalkPage(); + } elseif( $message->getType() == UserMessage::MSG_AUTH ) { + if( !$wgAuthLoggingNotifications || $wgAuthLoggingUserPref && !$user->getOption( "invalidloginnotification", false ) ) { + continue; + } + $page = Title::makeTitle( NS_SPECIAL, 'Log/auth' ); + } else { + continue; } - } elseif ( count( $newtalks ) ) { - // _>" " for BC <= 1.16 - $sep = str_replace( '_', ' ', $this->msg( 'newtalkseparator' )->escaped() ); - $msgs = array(); - - foreach ( $newtalks as $newtalk ) { - $msgs[] = Xml::element( - 'a', - array( 'href' => $newtalk['link'] ), $newtalk['wiki'] - ); + + if( $page->equals( $title ) ) { + $message->update( UserMessage::NOTSET ); + $user->invalidateCache(); + continue; } - $parts = implode( $sep, $msgs ); - $ntl = $this->msg( 'youhavenewmessagesmulti' )->rawParams( $parts )->escaped(); - $out->setSquidMaxage( 0 ); + + $link = Linker::linkKnown( + $page, + $message->getMessage()->escaped(), + array(), + array( 'redirect' => 'no' ) + ); + + $ntls[] = $this->msg( + 'newmessages-here', + $link + )->text(); + + # Disable Squid cache + $this->getOutput()->setSquidMaxage( 0 ); } - return $ntl; + $break = Html::element( 'br' ); + return implode( $break, $ntls ); } /** diff --git a/includes/User.php b/includes/User.php index 01407b1..6d70390 100644 --- a/includes/User.php +++ b/includes/User.php @@ -1740,154 +1740,6 @@ class User { } /** - * Check if the user has new messages. - * @return Bool True if the user has new messages - */ - public function getNewtalk() { - $this->load(); - - # Load the newtalk status if it is unloaded (mNewtalk=-1) - if( $this->mNewtalk === -1 ) { - $this->mNewtalk = false; # reset talk page status - - # Check memcached separately for anons, who have no - # entire User object stored in there. - if( !$this->mId ) { - global $wgMemc; - $key = wfMemcKey( 'newtalk', 'ip', $this->getName() ); - $newtalk = $wgMemc->get( $key ); - if( strval( $newtalk ) !== '' ) { - $this->mNewtalk = (bool)$newtalk; - } else { - // Since we are caching this, make sure it is up to date by getting it - // from the master - $this->mNewtalk = $this->checkNewtalk( 'user_ip', $this->getName(), true ); - $wgMemc->set( $key, (int)$this->mNewtalk, 1800 ); - } - } else { - $this->mNewtalk = $this->checkNewtalk( 'user_id', $this->mId ); - } - } - - return (bool)$this->mNewtalk; - } - - /** - * Return the talk page(s) this user has new messages on. - * @return Array of String page URLs - */ - public function getNewMessageLinks() { - $talks = array(); - if( !wfRunHooks( 'UserRetrieveNewTalks', array( &$this, &$talks ) ) ) - return $talks; - - if( !$this->getNewtalk() ) - return array(); - $up = $this->getUserPage(); - $utp = $up->getTalkPage(); - return array( array( 'wiki' => wfWikiID(), 'link' => $utp->getLocalURL() ) ); - } - - /** - * Internal uncached check for new messages - * - * @see getNewtalk() - * @param $field String 'user_ip' for anonymous users, 'user_id' otherwise - * @param $id String|Int User's IP address for anonymous users, User ID otherwise - * @param $fromMaster Bool true to fetch from the master, false for a slave - * @return Bool True if the user has new messages - */ - protected function checkNewtalk( $field, $id, $fromMaster = false ) { - if ( $fromMaster ) { - $db = wfGetDB( DB_MASTER ); - } else { - $db = wfGetDB( DB_SLAVE ); - } - $ok = $db->selectField( 'user_newtalk', $field, - array( $field => $id ), __METHOD__ ); - return $ok !== false; - } - - /** - * Add or update the new messages flag - * @param $field String 'user_ip' for anonymous users, 'user_id' otherwise - * @param $id String|Int User's IP address for anonymous users, User ID otherwise - * @return Bool True if successful, false otherwise - */ - protected function updateNewtalk( $field, $id ) { - $dbw = wfGetDB( DB_MASTER ); - $dbw->insert( 'user_newtalk', - array( $field => $id ), - __METHOD__, - 'IGNORE' ); - if ( $dbw->affectedRows() ) { - wfDebug( __METHOD__ . ": set on ($field, $id)\n" ); - return true; - } else { - wfDebug( __METHOD__ . " already set ($field, $id)\n" ); - return false; - } - } - - /** - * Clear the new messages flag for the given user - * @param $field String 'user_ip' for anonymous users, 'user_id' otherwise - * @param $id String|Int User's IP address for anonymous users, User ID otherwise - * @return Bool True if successful, false otherwise - */ - protected function deleteNewtalk( $field, $id ) { - $dbw = wfGetDB( DB_MASTER ); - $dbw->delete( 'user_newtalk', - array( $field => $id ), - __METHOD__ ); - if ( $dbw->affectedRows() ) { - wfDebug( __METHOD__ . ": killed on ($field, $id)\n" ); - return true; - } else { - wfDebug( __METHOD__ . ": already gone ($field, $id)\n" ); - return false; - } - } - - /** - * Update the 'You have new messages!' status. - * @param $val Bool Whether the user has new messages - */ - public function setNewtalk( $val ) { - if( wfReadOnly() ) { - return; - } - - $this->load(); - $this->mNewtalk = $val; - - if( $this->isAnon() ) { - $field = 'user_ip'; - $id = $this->getName(); - } else { - $field = 'user_id'; - $id = $this->getId(); - } - global $wgMemc; - - if( $val ) { - $changed = $this->updateNewtalk( $field, $id ); - } else { - $changed = $this->deleteNewtalk( $field, $id ); - } - - if( $this->isAnon() ) { - // Anons have a separate memcached space, since - // user records aren't kept for them. - $key = wfMemcKey( 'newtalk', 'ip', $id ); - $wgMemc->set( $key, $val ? 1 : 0, 1800 ); - } - if ( $changed ) { - $this->invalidateCache(); - } - } - - /** * Generate a current or new-future timestamp to be stored in the * user_touched field when we update things. * @return String Timestamp in TS_MW format @@ -2661,7 +2513,8 @@ class User { $title->getText() == $this->getName() ) { if( !wfRunHooks( 'UserClearNewTalkNotification', array( &$this ) ) ) return; - $this->setNewtalk( false ); + $msg = new UserMessage( $this, UserMessage::MSG_TALK ); + $msg->update( UserMessage::NOTSET ); } if( !$wgUseEnotif && !$wgShowUpdatedMarker ) { @@ -2696,7 +2549,8 @@ class User { public function clearAllNotifications() { global $wgUseEnotif, $wgShowUpdatedMarker; if ( !$wgUseEnotif && !$wgShowUpdatedMarker ) { - $this->setNewtalk( false ); + $msg = new UserMessage( $this, UserMessage::MSG_TALK ); + $msg->update( UserMessage::NOTSET ); return; } $id = $this->getId(); diff --git a/includes/UserMessage.php b/includes/UserMessage.php new file mode 100644 index 0000000..2d4f7ff --- /dev/null +++ b/includes/UserMessage.php @@ -0,0 +1,303 @@ + 'talk', + UserMessage::MSG_AUTH => 'auth' + ); + + /** + * User the message is for + * @var User $user + */ + private $user; + + /** + * Type of the message + * @var string $type + */ + private $type; + + /** + * Timestamp the message was created + * @var string $timestamp + */ + private $timestamp; + + /** + * Status of the message. Can be NOTLOADED if the status has + * not been set yet, NOTSET if the user doesn't have this specific + * message type, or SET if the user has the message. + * @var int $status + */ + private $status; + + /** + * Get an array of all messages the given user has. + * @param User|string $user User or IP address to get messages for + * @return Array List of UserMessage objects + */ + public static function getUserMessages( $user ) { + global $wgMemc; + $db = wfGetDB( DB_SLAVE ); + + if( is_string( $user ) ) { + $field = 'user_ip'; + $value = $user; + } else { + $field = 'user_id'; + $value = $user->getId(); + } + + $messages = array(); + + // Check memcached first. + $memKey = wfMemcKey( 'user', 'message', $value ); + $types = $wgMemc->get( $memKey ); + if( is_array( $types ) ) { + foreach( $types as $type => $timestamp ) { + $messages[] = new UserMessage( $user, $type, $timestamp, UserMessage::SET ); + } + } else { + // Cache miss. Go to database. + $res = $db->select( + 'user_newtalk', + array( 'user_last_timestamp', 'user_msg_type' ), + array( $field => $value ) + ); + + $toCache = array(); + foreach( $res as $row ) { + $messages[] = new UserMessage( + $user, + $row->user_msg_type, + $row->user_last_timestamp, + UserMessage::SET + ); + $toCache[$row->user_msg_type] = $row->user_last_timestamp; + } + $wgMemc->set( $memKey, $toCache, 72 * 3600 ); + } + return $messages; + } + + /** + * Clear all messages for the user. + */ + public static function clearMessages( $user ) { + global $wgMemc; + $db = wfGetDB( DB_SLAVE ); + + if( is_string( $user ) ) { + $field = 'user_ip'; + $value = $user; + } else { + $field = 'user_id'; + $value = $user->getId(); + } + + // Clear memcached (but don't delete key as this will cause an unnecessary + // DB query next time). + $memKey = wfMemcKey( 'user', 'message', $value ); + $wgMemc->replace( $memKey, array() ); + + // Clear database. + $res = $db->delete( 'user_newtalk', array( $field => $value ) ); + } + + /** + * Put together a new UserMessage object by storing the user, type, + * and timestamp. + */ + public function __construct( $user, $type, $timestamp = false, $status = UserMessage::NOTLOADED ) { + $this->user = $user; + $this->type = $type; + $this->timestamp = $timestamp; + $this->status = $status; + } + + /** + * Get the user associated with the message. + * @return User|string $user User or IP address to get messages for + */ + public function getUser() { + return $this->user; + } + + /** + * Get the type of the message. + * @return string The message type + */ + public function getType() { + return $this->type; + } + + /** + * Get the timestamp of the message in the specified format. + * @param string $type Output type of the timestamp + * @return string Timestamp for the message + */ + public function getTimestamp( $type ) { + if( $this->timestamp === false ) { + return false; + } else { + return wfTimestamp( $type, $this->timestamp ); + } + } + + /** + * Get the status of the message, i.e., whether the user has this + * message yet. + * @return int Whether the user has the message or not (see class constants) + */ + public function getStatus() { + return $this->status; + } + + /** + * Get the string message of this message, which is whatever message + * is stored under the key newmessages-type-$type. + * @return string The message + */ + public function getMessage() { + $type = UserMessage::$typeNames[$this->type]; + $msg = "newmessages-type-$type"; + return RequestContext::getMain()->msg( $msg ); + } + + /** + * Load the message from the database. If the user doesn't have this + * specific message, set the timestamp to false and status to NOTSET. + * Otherwise, store the timestamp of the message and set the status to SET. + */ + public function load() { + global $wgMemc; + if( is_string( $this->user ) ) { + $field = 'user_ip'; + $value = $this->user; + } else { + $field = 'user_id'; + $value = $this->user->getId(); + } + + // Try memcached first, then database. + $memKey = wfMemcKey( 'user', 'message', $value ); + $types = $wgMemc->get( $memKey ); + if( is_array( $types ) ) { + // Memcached is valid. + if( array_key_exists( $this->type, $types ) ) { + $this->status = self::SET; + $this->timestamp = $types[$this->type]; + } else { + $this->status = self::NOTSET; + $this->timestamp = false; + } + } else { + // Cache miss. Get from DB. + $db = wfGetDB( DB_SLAVE ); + $res = $db->selectField( + 'user_newtalk', + 'user_last_timestamp', + array( $field => $value, 'user_msg_type' => $this->type ), + array( 'IGNORE' ) + ); + + $this->status = $res === false ? UserMessage::NOTSET : UserMessage::SET; + $this->timestamp = $res; + } + } + + /** + * Update the status of the message. If the status is updated from + * SET to NOTSET, delete it from the database. If vice-versa, add it + * to the database. Otherwise, no action. + * @param int Whether the user has the message or not (see class constants) + * @return bool True if the status changed, false otherwise + */ + public function update( $status ) { + global $wgMemc; + if( $status == $this->status ) { + return false; + } + + $this->status = $status; + + if( is_string( $this->user ) ) { + $field = 'user_ip'; + $value = $this->user; + } else { + $field = 'user_id'; + $value = $this->user->getId(); + } + + $db = wfGetDB( DB_MASTER ); + $memKey = wfMemcKey( 'user', 'message', $value ); + $types = $wgMemc->get( $memKey ); + + if( $status == UserMessage::SET ) { + $this->timestamp = wfTimestamp( TS_MW ); + $indices = array( array( 'user_msg_type', $field ) ); + $row = array( + $field => $value, + 'user_last_timestamp' => $this->timestamp, + 'user_msg_type' => $this->type + ); + + $db->replace( 'user_newtalk', $indices, $row, __METHOD__ ); + + if( is_array( $types ) ) { + $types[$this->type] = $this->timestamp; + } else { + $types = array( $this->type => $this->timestamp ); + } + } else { + $this->timestamp = false; + $where = array( $field => $value, 'user_msg_type' => $this->type ); + $db->delete( 'user_newtalk', $where, __METHOD__ ); + + if( is_array( $types ) && array_key_exists( $this->type, $types ) ) { + unset( $types[$this->type] ); + } + } + + $wgMemc->set( $memKey, $types ); + + $this->user->invalidateCache(); + return true; + } +} \ No newline at end of file diff --git a/includes/WikiPage.php b/includes/WikiPage.php index 40e0ec3..56b66cb 100644 --- a/includes/WikiPage.php +++ b/includes/WikiPage.php @@ -1832,13 +1832,14 @@ class WikiPage extends Page { ) { if ( wfRunHooks( 'ArticleEditUpdateNewTalk', array( &$this ) ) ) { $other = User::newFromName( $shortTitle, false ); + $msg = new UserMessage( $other, UserMessage::MSG_TALK ); if ( !$other ) { wfDebug( __METHOD__ . ": invalid username\n" ); } elseif ( User::isIP( $shortTitle ) ) { // An anonymous user - $other->setNewtalk( true ); + $msg->update( UserMessage::SET ); } elseif ( $other->isLoggedIn() ) { - $other->setNewtalk( true ); + $msg->update( UserMessage::SET ); } else { wfDebug( __METHOD__ . ": don't need to notify a nonexistent user\n" ); } @@ -2538,8 +2539,9 @@ class WikiPage extends Page { # User talk pages if ( $title->getNamespace() == NS_USER_TALK ) { $user = User::newFromName( $title->getText(), false ); + $msg = new UserMessage( $user, UserMessage::MSG_TALK ); if ( $user ) { - $user->setNewtalk( false ); + $msg->update( UserMessage::NOTSET ); } } diff --git a/includes/db/Database.php b/includes/db/Database.php index 0a1f988..ac2a0bc 100644 --- a/includes/db/Database.php +++ b/includes/db/Database.php @@ -1846,20 +1846,20 @@ abstract class DatabaseBase implements DatabaseType { // Don't necessarily assume the single key is 0; we don't // enforce linear numeric ordering on other arrays here. $value = array_values( $value ); - $list .= $field . " = " . $this->addQuotes( $value[0] ); + $list .= $this->addIdentifierQuotes( $field ) . " = " . $this->addQuotes( $value[0] ); } else { - $list .= $field . " IN (" . $this->makeList( $value ) . ") "; + $list .= $this->addIdentifierQuotes( $field ) . " IN (" . $this->makeList( $value ) . ") "; } } elseif ( $value === null ) { if ( $mode == LIST_AND || $mode == LIST_OR ) { - $list .= "$field IS "; + $list .= $this->addIdentifierQuotes( $field ) . " IS "; } elseif ( $mode == LIST_SET ) { - $list .= "$field = "; + $list .= $this->addIdentifierQuotes( $field ) . " = "; } $list .= 'NULL'; } else { if ( $mode == LIST_AND || $mode == LIST_OR || $mode == LIST_SET ) { - $list .= "$field = "; + $list .= $this->addIdentifierQuotes( $field ) . " = "; } $list .= $mode == LIST_NAMES ? $value : $this->addQuotes( $value ); } diff --git a/includes/installer/MysqlUpdater.php b/includes/installer/MysqlUpdater.php index e453b01..8292d5e 100644 --- a/includes/installer/MysqlUpdater.php +++ b/includes/installer/MysqlUpdater.php @@ -213,6 +213,7 @@ class MysqlUpdater extends DatabaseUpdater { array( 'addIndex', 'revision', 'page_user_timestamp', 'patch-revision-user-page-index.sql' ), array( 'addField', 'ipblocks', 'ipb_parent_block_id', 'patch-ipb-parent-block-id.sql' ), array( 'addIndex', 'ipblocks', 'ipb_parent_block_id', 'patch-ipb-parent-block-id-index.sql' ), + array( 'addField', 'user_newtalk', 'user_newtalk_type', 'patch-user_newtalk_type.sql' ), ); } diff --git a/includes/installer/OracleUpdater.php b/includes/installer/OracleUpdater.php index aa3c334..2c72492 100644 --- a/includes/installer/OracleUpdater.php +++ b/includes/installer/OracleUpdater.php @@ -69,6 +69,7 @@ class OracleUpdater extends DatabaseUpdater { //1.20 array( 'addTable', 'config', 'patch-config.sql' ), + array( 'addField', 'user_newtalk', 'user_newtalk_type', 'patch-user_newtalk_type.sql' ), // KEEP THIS AT THE BOTTOM!! array( 'doRebuildDuplicateFunction' ), diff --git a/includes/installer/PostgresUpdater.php b/includes/installer/PostgresUpdater.php index 43005a8..3d5dbbb 100644 --- a/includes/installer/PostgresUpdater.php +++ b/includes/installer/PostgresUpdater.php @@ -330,6 +330,7 @@ class PostgresUpdater extends DatabaseUpdater { # r81574 array( 'addInterwikiType' ), # end + array( 'addPgField', 'user_newtalk', 'user_newtalk_type', 'patch-user_newtalk_type.sql' ), array( 'tsearchFixes' ), ); } diff --git a/includes/installer/SqliteUpdater.php b/includes/installer/SqliteUpdater.php index 8146274..cabe3f0 100644 --- a/includes/installer/SqliteUpdater.php +++ b/includes/installer/SqliteUpdater.php @@ -92,6 +92,7 @@ class SqliteUpdater extends DatabaseUpdater { array( 'addIndex', 'revision', 'page_user_timestamp', 'patch-revision-user-page-index.sql' ), array( 'addField', 'ipblocks', 'ipb_parent_block_id', 'patch-ipb-parent-block-id.sql' ), array( 'addIndex', 'ipblocks', 'ipb_parent_block_id', 'patch-ipb-parent-block-id-index.sql' ), + array( 'addField', 'user_newtalk', 'user_newtalk_type', 'patch-user_newtalk_type.sql' ), ); } diff --git a/includes/logging/LogEventsList.php b/includes/logging/LogEventsList.php index 04df226..65824ae 100644 --- a/includes/logging/LogEventsList.php +++ b/includes/logging/LogEventsList.php @@ -726,21 +726,48 @@ class LogEventsList { * @return Mixed: string or false */ public static function getExcludeClause( $db, $audience = 'public' ) { - global $wgLogRestrictions, $wgUser; + global $wgLogRestrictions, $wgUser, $wgRequest; // Reset the array, clears extra "where" clauses when $par is used $hiddenLogs = array(); + $ownOnlyLogs = array(); // Don't show private logs to unprivileged users - foreach( $wgLogRestrictions as $logType => $right ) { - if( $audience == 'public' || !$wgUser->isAllowed($right) ) { - $safeType = $db->strencode( $logType ); - $hiddenLogs[] = $safeType; + foreach( $wgLogRestrictions as $index => $val ) { + if( $audience == 'public' ) { + $hiddenLogs[] = $index; + } elseif( is_array( $val ) && !$wgUser->isAllowed( $val['all'] ) ) { + if( $wgUser->isAllowed( $val['own'] ) ) { + $ownOnlyLogs[] = $index; + } else { + $hiddenLogs[] = $index; + } + } elseif( is_string( $val ) && !$wgUser->isAllowed( $val ) ) { + $hiddenLogs[] = $index; } } + + $cond = ''; if( count($hiddenLogs) == 1 ) { - return 'log_type != ' . $db->addQuotes( $hiddenLogs[0] ); + $cond .= $db->addIdentifierQuotes( 'log_type' ) . ' != ' . $db->addQuotes( $hiddenLogs[0] ); } elseif( $hiddenLogs ) { - return 'log_type NOT IN (' . $db->makeList($hiddenLogs) . ')'; + $cond .= $db->addIdentifierQuotes( 'log_type' ) . ' NOT IN (' . $db->makeList( $hiddenLogs ) . ')'; + } else { + $cond .= ''; + } + + if( $ownOnlyLogs ) { + $userConds = array( 'log_namespace' => NS_USER, 'log_title' => $wgUser->getName() ); + if( count( $ownOnlyLogs ) == 1 ) { + $typeCond = $db->addIdentifierQuotes( 'log_type' ) . ' = ' . $db->addQuotes( $ownOnlyLogs[0] ); + } else { + $typeCond = $db->addIdentifierQuotes( 'log_type' ) . 'NOT IN (' . $db->makeList( $ownOnlyLogs ) . ')'; + } + + if( $cond ) { + $cond .= ' AND '; + } + $cond .= 'NOT ( ' . $typeCond . ' AND NOT ( ' . $db->makeList( $userConds, LIST_AND ) . ' ) )'; } - return false; + + return $cond; } } diff --git a/includes/logging/LogFormatter.php b/includes/logging/LogFormatter.php index 1ba6a3b..be3efad 100644 --- a/includes/logging/LogFormatter.php +++ b/includes/logging/LogFormatter.php @@ -431,7 +431,12 @@ class LogFormatter { public function getPerformerElement() { if ( $this->canView( LogPage::DELETED_USER ) ) { $performer = $this->entry->getPerformer(); - $element = $this->makeUserLink( $performer ); + if( $performer->getName() == '0.0.0.0' ) { + $element = wfMsg( 'log-anonymous' ); + } else { + $element = $this->makeUserLink( $performer ); + } + if ( $this->entry->isDeleted( LogPage::DELETED_USER ) ) { $element = $this->styleRestricedElement( $element ); } @@ -736,3 +741,15 @@ class NewUsersLogFormatter extends LogFormatter { return array(); } } + + +/** + * This class formats auth log entries. + */ + class AuthLogFormatter extends LogFormatter { + protected function getMessageParameters() { + $params = parent::getMessageParameters(); + $params[3] = $params[3] ? wfMsg( 'authlog-success' ) : wfMsg( 'authlog-failed' ); + return $params; + } +} \ No newline at end of file diff --git a/includes/logging/LogPage.php b/includes/logging/LogPage.php index 3891f34..d222c18 100644 --- a/includes/logging/LogPage.php +++ b/includes/logging/LogPage.php @@ -628,10 +628,15 @@ class LogPage { * @return string * @since 1.19 */ - public function getRestriction() { + public function getRestriction($own = false) { global $wgLogRestrictions; if ( isset( $wgLogRestrictions[$this->type] ) ) { - $restriction = $wgLogRestrictions[$this->type]; + if( is_array( $wgLogRestrictions[$this->type] ) ) { + $key = $own ? 'own' : 'all'; + $restriction = $wgLogRestrictions[$this->type][$key]; + } else { + $restriction = $wgLogRestrictions[$this->type]; + } } else { // '' always returns true with $user->isAllowed() $restriction = ''; diff --git a/includes/logging/LogPager.php b/includes/logging/LogPager.php index ea1be8e..a4409a0 100644 --- a/includes/logging/LogPager.php +++ b/includes/logging/LogPager.php @@ -100,8 +100,12 @@ class LogPager extends ReverseChronologicalPager { // Don't even show header for private logs; don't recognize it... $needReindex = false; foreach ( $types as $type ) { - if( isset( $wgLogRestrictions[$type] ) - && !$this->getUser()->isAllowed($wgLogRestrictions[$type]) + if( !isset( $wgLogRestrictions[$type] ) ) { + continue; + } elseif( is_string( $wgLogRestrictions[$type] ) + && !$this->getUser()->isAllowed($wgLogRestrictions[$type]) || + is_array( $wgLogRestrictions[$type] ) + && !$this->getUser()->isAllowed($wgLogRestrictions[$type]['own']) ) { $needReindex = true; $types = array_diff( $types, array( $type ) ); diff --git a/includes/specials/SpecialLog.php b/includes/specials/SpecialLog.php index 8eee22d..01f60bd 100644 --- a/includes/specials/SpecialLog.php +++ b/includes/specials/SpecialLog.php @@ -39,6 +39,7 @@ class SpecialLog extends SpecialPage { 'block', 'newusers', 'rights', + 'auth' ); public function __construct() { @@ -51,7 +52,7 @@ class SpecialLog extends SpecialPage { $this->setHeaders(); $this->outputHeader(); - $opts = new FormOptions; + $opts = new FormOptions(); $opts->add( 'type', '' ); $opts->add( 'user', '' ); $opts->add( 'page', '' ); @@ -78,9 +79,10 @@ class SpecialLog extends SpecialPage { // Reset the log type to default (nothing) if it's invalid or if the // user does not possess the right to view it $type = $opts->getValue( 'type' ); + $restriction = is_array( $wgLogRestrictions[$type] ) ? $wgLogRestrictions[$type]['own'] : $wgLogRestrictions[$type]; if ( !LogPage::isLogType( $type ) || ( isset( $wgLogRestrictions[$type] ) - && !$this->getUser()->isAllowed( $wgLogRestrictions[$type] ) ) + && !$this->getUser()->isAllowed( $restriction ) ) ) { $opts->setValue( 'type', '' ); } diff --git a/includes/specials/SpecialUserlogin.php b/includes/specials/SpecialUserlogin.php index ded2721..35cebbf 100644 --- a/includes/specials/SpecialUserlogin.php +++ b/includes/specials/SpecialUserlogin.php @@ -482,7 +482,9 @@ class LoginForm extends SpecialPage { * @return int */ public function authenticateUserData() { - global $wgUser, $wgAuth; + global $wgUser, $wgAuth, $wgRequest; + global $wgAuthLogging, $wgAuthLoggingStoreIP, $wgAuthLoggingStoreValid; + global $wgAuthLoggingEmail, $wgAuthLoggingUserPref, $wgAuthLoggingGracePeriod; $this->load(); @@ -616,6 +618,77 @@ class LoginForm extends SpecialPage { $retval = self::SUCCESS; } + + // If logging is enabled, and either valid login logging is enabled or this is an invalid login. + if( $wgAuthLogging && ( $wgAuthLoggingStoreValid || $retval != self::SUCCESS ) ) { + $res = ( $retval == self::SUCCESS ) ? true : false; + $anon = new User(); + $anon->setName( '0.0.0.0' ); + $params = array( '4:bool:result' => $res ); + + $entry = new ManualLogEntry( "auth", "login" ); + $entry->setTarget( Title::makeTitle( NS_USER, $this->mUsername ) ); + $entry->setPerformer( $wgAuthLoggingStoreIP ? $wgUser : $anon ); + $entry->setParameters( $params ); + $entry->insert(); + } + + // If the login wasn't successful, either send an email if above the threshold or set a notification. + if( $retval != self::SUCCESS ) { + // Get user's auth message. + $msg = new UserMessage( $u, UserMessage::MSG_AUTH ); + $msg->load(); + + // First check if emailing is enabled and if user set preferences to enable it (or if preferences are disabled). + // Also check if the AUTH message is set. If it's not set, then the number of invalid logins is 0. + if( $wgAuthLoggingEmail + && ( !$wgAuthLoggingUserPref || $u->getOption( "invalidloginemail", false ) ) + && $msg->getStatus() == UserMessage::SET ) { + $db = wfGetDB( DB_SLAVE ); + $queryData = DatabaseLogEntry::getSelectQueryData(); + $conds = $queryData['conds']; + $options = $queryData['options']; + + // Get all invalid logins that happened after the AUTH message timestamp or the configured + // time period, whatever is less. + $baseline = max( $msg->getTimestamp( TS_UNIX ), wfTimestamp( TS_UNIX ) - $wgAuthLoggingEmail['period'] * 3600 ); + $time_limit = wfTimestamp( TS_MW, $baseline ); + $conds['log_namespace'] = NS_USER; + $conds['log_title'] = $this->mUsername; + $conds[] = 'log_timestamp > ' . $db->addQuotes( $time_limit ); + // No need to get more than the threshold. Being above it is an automatic trigger. + $options['LIMIT'] = $wgAuthLoggingEmail['threshold']; + + // Now we need to filter for only log lines that were invalid logins. + $res = $db->select( 'logging', 'log_params', $conds, $options ); + $numLogins = 0; + if( $wgAuthLoggingStoreValid ) { + foreach( $res as $row ) { + $entry = DatabaseLogEntry::newFromRow( $row ); + $params = $entry->getParameters(); + if( $params['4:bool:result'] == false ) { + $numLogins++; + } + } + } else { + // If valid login logging isn't enabled, then they all must be invalid. + $numLogins = $res->numRows(); + } + + if( $numLogins >= $wgAuthLoggingEmail['threshold'] ) { + $subject = wfMsg( 'enotif-auth-subject' ); + $body = wfMsg( 'enotif-auth-body', $u->getName(), $wgAuthLoggingEmail['threshold'], $wgAuthLoggingEmail['period'] ); + $u->sendMail( $subject, $body ); + $msg = new UserMessage( $u, UserMessage::MSG_AUTH ); + $msg->update( UserMessage::NOTSET ); + } + } else { + $msg = new UserMessage( $u, UserMessage::MSG_AUTH ); + $msg->update( UserMessage::SET ); + $u->invalidateCache(); + } + } + wfRunHooks( 'LoginAuthenticateAudit', array( $u, $this->mPassword, $retval ) ); return $retval; } diff --git a/languages/messages/MessagesEn.php b/languages/messages/MessagesEn.php index b7275f8..7944a12 100644 --- a/languages/messages/MessagesEn.php +++ b/languages/messages/MessagesEn.php @@ -903,11 +903,11 @@ See [[Special:Version|version page]].', 'pagetitle-view-mainpage' => '{{SITENAME}}', # only translate this message to other languages if you have to change it 'backlinksubtitle' => '← $1', # only translate this message to other languages if you have to change it 'retrievedfrom' => 'Retrieved from "$1"', -'youhavenewmessages' => 'You have $1 ($2).', -'newmessageslink' => 'new messages', -'newmessagesdifflink' => 'last change', -'youhavenewmessagesmulti' => 'You have new messages on $1', -'newtalkseparator' => ', ', # do not translate or duplicate this message to other languages +'newmessages-here' => 'You have new $1.', +'newmessages-there' => 'You have new $1 on $2.', +'newmessages-type-talk' => 'talk page messages', +'newmessages-type-auth' => 'failed logins', +'newmessages-sep' => ', ', # do not translate or duplicate this message to other languages 'editsection' => 'edit', 'editsection-brackets' => '[$1]', # only translate this message to other languages if you have to change it 'editold' => 'edit', @@ -1172,6 +1172,10 @@ Please wait before trying again.', * Nederlands|nl', # do not translate or duplicate this message to other languages 'suspicious-userlogout' => 'Your request to log out was denied because it looks like it was sent by a broken browser or caching proxy.', +# Auth log +'authlogpage' => 'Auth log', +'authlogtext' => 'This is a list of authentication events, i.e., login attempts.', + # E-mail sending 'pear-mail-error' => '$1', # do not translate or duplicate this message to other languages 'php-mail-error' => '$1', # do not translate or duplicate this message to other languages @@ -1809,6 +1813,8 @@ Note that their indexes of {{SITENAME}} content may be out of date.', 'prefs-setemail' => 'Set an e-mail address', 'prefs-email' => 'E-mail options', 'prefs-rendering' => 'Appearance', +'prefs-authnotify' => 'Enable notifications for invalid logins.', +'prefs-authemail' => 'Enable e-mail notifications for when $1 invalid logins occur in $2 hours.', 'saveprefs' => 'Save', 'resetprefs' => 'Clear unsaved changes', 'restoreprefs' => 'Restore all default settings', @@ -2877,6 +2883,22 @@ $UNWATCHURL Feedback and further assistance: {{canonicalurl:{{MediaWiki:Helppage}}}}', +# Auth notifications +'enotif-auth-subject' => '{{SITENAME}} - Account Security Notification', +'enotif-auth-body' => 'Dear $1, + +Over the past $2 hours, more than $3 invalid login attempts have been made to your account. It is recommended you login to your +account, check to make sure nobody has accessed your account without your knowledge, and check to make sure your password is secure. + + Your friendly {{SITENAME}} notification system + +-- +To change your e-mail notification settings, visit +{{canonicalurl:{{#special:Preferences}}}} + +Feedback and further assistance: +{{canonicalurl:{{MediaWiki:Helppage}}}}', + # Delete 'deletepage' => 'Delete page', 'confirm' => 'Confirm', @@ -4756,6 +4778,7 @@ This site is experiencing technical difficulties.', 'sqlite-no-fts' => '$1 without full-text search support', # New logging system +'log-anonymous' => 'Somebody', 'logentry-delete-delete' => '$1 deleted page $3', 'logentry-delete-restore' => '$1 restored page $3', 'logentry-delete-event' => '$1 changed visibility of {{PLURAL:$5|a log event|$5 log events}} on $3: $4', @@ -4785,7 +4808,10 @@ This site is experiencing technical difficulties.', 'logentry-newusers-create' => '$1 created a user account', 'logentry-newusers-create2' => '$1 created a user account $3', 'logentry-newusers-autocreate' => 'Account $1 was created automatically', +'logentry-auth-login' => '$1 $4 logging in to $3.', 'newuserlog-byemail' => 'password sent by e-mail', +'authlog-success' => 'succeeded', +'authlog-failed' => 'failed', # For IRC, see bug 34508. Do not change 'revdelete-logentry' => 'changed revision visibility of "[[$1]]"', # do not translate or duplicate this message to other languages diff --git a/maintenance/archives/patch-user_newtalk_type.sql b/maintenance/archives/patch-user_newtalk_type.sql new file mode 100644 index 0000000..6e18fac --- /dev/null +++ b/maintenance/archives/patch-user_newtalk_type.sql @@ -0,0 +1,16 @@ +-- Add the user_msg_type column to user_newtalk and create indices + +ALTER TABLE /*_*/user_newtalk + ADD user_msg_type int unsigned; + +UPDATE /*_*/user_newtalk + SET user_msg_type=0; + +ALTER TABLE /*_*/user_newtalk + MODIFY user_msg_type int unsigned NOT NULL default 0; + +DROP INDEX /*i*/un_user_id ON /*_*/user_newtalk; +DROP INDEX /*i*/un_user_ip ON /*_*/user_newtalk; + +CREATE UNIQUE INDEX /*i*/un_user_id ON /*_*/user_newtalk (user_id, user_msg_type); +CREATE UNIQUE INDEX /*i*/un_user_ip ON /*_*/user_newtalk (user_ip, user_msg_type); \ No newline at end of file diff --git a/maintenance/mssql/tables.sql b/maintenance/mssql/tables.sql index a0c3d17..767673b 100644 --- a/maintenance/mssql/tables.sql +++ b/maintenance/mssql/tables.sql @@ -72,9 +72,10 @@ CREATE TABLE /*$wgDBprefix*/user_newtalk ( user_id INT NOT NULL DEFAULT 0 REFERENCES /*$wgDBprefix*/[user](user_id) ON DELETE CASCADE, user_ip NVARCHAR(40) NOT NULL DEFAULT '', user_last_timestamp DATETIME NOT NULL DEFAULT '', + user_msg_type INT NOT NULL DEFAULT 0 ); -CREATE INDEX /*$wgDBprefix*/user_group_id ON /*$wgDBprefix*/user_newtalk([user_id]); -CREATE INDEX /*$wgDBprefix*/user_ip ON /*$wgDBprefix*/user_newtalk(user_ip); +CREATE UNIQUE INDEX /*$wgDBprefix*/user_group_id ON /*$wgDBprefix*/user_newtalk([user_id], user_msg_type); +CREATE UNIQUE INDEX /*$wgDBprefix*/user_ip ON /*$wgDBprefix*/user_newtalk(user_ip, user_msg_type); -- -- User preferences and other fun stuff diff --git a/maintenance/oracle/archives/patch-user_newtalk_type.sql b/maintenance/oracle/archives/patch-user_newtalk_type.sql new file mode 100644 index 0000000..8be3c9d --- /dev/null +++ b/maintenance/oracle/archives/patch-user_newtalk_type.sql @@ -0,0 +1,8 @@ +-- Add the user_msg_type column to user_newtalk and create indices + +ALTER TABLE &mw_prefix.user_newtalk ADD user_msg_type int unsigned; +UPDATE &mw_prefix.user_newtalk SET user_msg_type=0; +ALTER TABLE &mw_prefix.user_newtalk MODIFY user_msg_type INTEGER NOT NULL default 0; + +DROP INDEX &mw_prefix.un_user_id ON user_newtalk; +DROP INDEX &mw_prefix.un_user_ip ON user_newtalk; \ No newline at end of file diff --git a/maintenance/oracle/tables.sql b/maintenance/oracle/tables.sql index 3722120..ee7979b 100644 --- a/maintenance/oracle/tables.sql +++ b/maintenance/oracle/tables.sql @@ -48,10 +48,11 @@ CREATE TABLE &mw_prefix.user_newtalk ( user_id NUMBER DEFAULT 0 NOT NULL, user_ip VARCHAR2(40) NULL, user_last_timestamp TIMESTAMP(6) WITH TIME ZONE + user_msg_type NUMBER DEFAULT 0 NOT NULL, ); ALTER TABLE &mw_prefix.user_newtalk ADD CONSTRAINT &mw_prefix.user_newtalk_fk1 FOREIGN KEY (user_id) REFERENCES &mw_prefix.mwuser(user_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED; -CREATE INDEX &mw_prefix.user_newtalk_i01 ON &mw_prefix.user_newtalk (user_id); -CREATE INDEX &mw_prefix.user_newtalk_i02 ON &mw_prefix.user_newtalk (user_ip); +CREATE UNIQUE INDEX &mw_prefix.user_newtalk_i01 ON &mw_prefix.user_newtalk (user_id, user_msg_type); +CREATE UNIQUE INDEX &mw_prefix.user_newtalk_i02 ON &mw_prefix.user_newtalk (user_ip, user_msg_type); CREATE TABLE &mw_prefix.user_properties ( up_user NUMBER NOT NULL, diff --git a/maintenance/postgres/archives/patch-user_newtalk_type.sql b/maintenance/postgres/archives/patch-user_newtalk_type.sql new file mode 100644 index 0000000..277f209 --- /dev/null +++ b/maintenance/postgres/archives/patch-user_newtalk_type.sql @@ -0,0 +1,16 @@ +-- Add the user_msg_type column to user_newtalk and create indices + +ALTER TABLE user_newtalk + ADD user_msg_type int unsigned; + +UPDATE user_newtalk + SET user_msg_type=0; + +ALTER TABLE user_newtalk + MODIFY user_msg_type INTEGER NOT NULL default 0; + +DROP INDEX un_user_id ON user_newtalk; +DROP INDEX un_user_ip ON user_newtalk; + +CREATE UNIQUE INDEX un_user_id ON user_newtalk (user_id, user_msg_type); +CREATE UNIQUE INDEX un_user_ip ON user_newtalk (user_ip, user_msg_type); \ No newline at end of file diff --git a/maintenance/postgres/tables.sql b/maintenance/postgres/tables.sql index 1e3eecb..8aba4ba 100644 --- a/maintenance/postgres/tables.sql +++ b/maintenance/postgres/tables.sql @@ -62,9 +62,10 @@ CREATE TABLE user_newtalk ( user_id INTEGER NOT NULL REFERENCES mwuser(user_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, user_ip TEXT NULL, user_last_timestamp TIMESTAMPTZ + user_msg_type INTEGER NOT NULL DEFAULT 0 ); -CREATE INDEX user_newtalk_id_idx ON user_newtalk (user_id); -CREATE INDEX user_newtalk_ip_idx ON user_newtalk (user_ip); +CREATE UNIQUE INDEX user_newtalk_id_idx ON user_newtalk (user_id, user_msg_type); +CREATE UNIQUE INDEX user_newtalk_ip_idx ON user_newtalk (user_ip, user_msg_type); CREATE SEQUENCE page_page_id_seq; diff --git a/maintenance/sqlite/archives/patch-user_newtalk_type.sql b/maintenance/sqlite/archives/patch-user_newtalk_type.sql new file mode 100644 index 0000000..6e18fac --- /dev/null +++ b/maintenance/sqlite/archives/patch-user_newtalk_type.sql @@ -0,0 +1,16 @@ +-- Add the user_msg_type column to user_newtalk and create indices + +ALTER TABLE /*_*/user_newtalk + ADD user_msg_type int unsigned; + +UPDATE /*_*/user_newtalk + SET user_msg_type=0; + +ALTER TABLE /*_*/user_newtalk + MODIFY user_msg_type int unsigned NOT NULL default 0; + +DROP INDEX /*i*/un_user_id ON /*_*/user_newtalk; +DROP INDEX /*i*/un_user_ip ON /*_*/user_newtalk; + +CREATE UNIQUE INDEX /*i*/un_user_id ON /*_*/user_newtalk (user_id, user_msg_type); +CREATE UNIQUE INDEX /*i*/un_user_ip ON /*_*/user_newtalk (user_ip, user_msg_type); \ No newline at end of file diff --git a/maintenance/tables.sql b/maintenance/tables.sql index 0a5b2fb..b8bf857 100644 --- a/maintenance/tables.sql +++ b/maintenance/tables.sql @@ -181,12 +181,14 @@ CREATE TABLE /*_*/user_newtalk ( user_ip varbinary(40) NOT NULL default '', -- The highest timestamp of revisions of the talk page viewed -- by this user - user_last_timestamp varbinary(14) NULL default NULL + user_last_timestamp varbinary(14) NULL default NULL, + -- The type of the message + user_msg_type int unsigned NOT NULL default 0 ) /*$wgDBTableOptions*/; -- Indexes renamed for SQLite in 1.14 -CREATE INDEX /*i*/un_user_id ON /*_*/user_newtalk (user_id); -CREATE INDEX /*i*/un_user_ip ON /*_*/user_newtalk (user_ip); +CREATE UNIQUE INDEX /*i*/un_user_id ON /*_*/user_newtalk (user_id, user_msg_type); +CREATE UNIQUE INDEX /*i*/un_user_ip ON /*_*/user_newtalk (user_ip, user_msg_type); -- diff --git a/tests/phpunit/includes/MessageTest.php b/tests/phpunit/includes/MessageTest.php index 20181fd..fea3064 100644 --- a/tests/phpunit/includes/MessageTest.php +++ b/tests/phpunit/includes/MessageTest.php @@ -30,8 +30,8 @@ class MessageTest extends MediaWikiLangTestCase { function testMessageParams() { $this->assertEquals( 'Return to $1.', wfMessage( 'returnto' )->text() ); $this->assertEquals( 'Return to $1.', wfMessage( 'returnto', array() )->text() ); - $this->assertEquals( 'You have foo (bar).', wfMessage( 'youhavenewmessages', 'foo', 'bar' )->text() ); - $this->assertEquals( 'You have foo (bar).', wfMessage( 'youhavenewmessages', array( 'foo', 'bar' ) )->text() ); + $this->assertEquals( 'You have new foo.', wfMessage( 'newmessages-here', 'foo' )->text() ); + $this->assertEquals( 'You have new foo.', wfMessage( 'newmessages-here', array( 'foo' ) )->text() ); } function testMessageParamSubstitution() { diff --git a/tests/phpunit/includes/UserMessageTest.php b/tests/phpunit/includes/UserMessageTest.php new file mode 100644 index 0000000..b74d849 --- /dev/null +++ b/tests/phpunit/includes/UserMessageTest.php @@ -0,0 +1,84 @@ +savedGroupPermissions = $wgGroupPermissions; + $this->savedRevokedPermissions = $wgRevokePermissions; + + $this->setUpPermissionGlobals(); + $this->setUpUser(); + + UserMessage::clearMessages( $this->user ); + } + + public function testDelivery() { + $msg = new UserMessage( $this->user, UserMessage::MSG_TALK ); + $this->assertTrue( $msg->update( UserMessage::SET ) ); + $this->assertEquals( $msg->getStatus(), UserMessage::SET ); + + $msg = new UserMessage( $this->user, UserMessage::MSG_TALK ); + $msg->load(); + $this->assertEquals( $msg->getStatus(), UserMessage::SET ); + + $msgs = UserMessage::getUserMessages( $this->user ); + $this->assertGreaterThanOrEqual( count( $msgs ), 1 ); + $this->assertEquals( $msgs[0]->getStatus(), UserMessage::SET ); + $this->assertEquals( $msgs[0]->getType(), UserMessage::MSG_TALK ); + } + + /** + * @depends testDelivery + */ + public function testClear() { + $msg = new UserMessage( $this->user, UserMessage::MSG_TALK ); + $this->load(); + $this->assertEquals( $msg->getStatus(), UserMessage::SET ); + + $this->assertTrue( $this->update( UserMessage::NOTSET ) ); + $this->assertEquals( $msg->getStatus(), UserMessage::NOTSET ); + + $msg = new UserMessage( $this->user, UserMessage::MSG_TALK ); + $this->assertEquals( $msg->getStatus(), UserMessage::NOTSET ); + + $msgs = UserMessage::getUserMessages( $this->user ); + $this->assertEmpty( $msgs ); + } + + private function setUpUser() { + $this->user = new User; + $this->user->addGroup( 'unittesters' ); + } + + private function setUpPermissionGlobals() { + global $wgGroupPermissions, $wgRevokePermissions; + + # Data for regular $wgGroupPermissions test + $wgGroupPermissions['unittesters'] = array( + 'test' => true, + 'runtest' => true, + 'writetest' => false, + 'nukeworld' => false, + ); + $wgGroupPermissions['testwriters'] = array( + 'test' => true, + 'writetest' => true, + 'modifytest' => true, + ); + # Data for regular $wgRevokePermissions test + $wgRevokePermissions['formertesters'] = array( + 'runtest' => true, + ); + } +} \ No newline at end of file