production.
=== Configuration changes in 1.29 ===
+* Default cookie expiration time has been reduced to 30 days. Login cookie expiration time is
+ kept at 180 days.
+* A new configuration variable has been added: $wgCookieSetOnAutoblock. This
+ determines whether to set a cookie when a user is autoblocked. Doing so means
+ that a blocked user, even after logging out and moving to a new IP address,
+ will still be blocked.
=== New features in 1.29 ===
+* (T5233) A cookie can now be set when a user is autoblocked, to track that user if
+ they move to a new IP address. This is disabled by default.
=== External library changes in 1.29 ===
$this->blocker = $user;
}
+ /**
+ * Set the 'BlockID' cookie to this block's ID and expiry time. The cookie's expiry will be
+ * the same as the block's, unless it's greater than $wgCookieExpiration in which case
+ * $wgCookieExpiration will be used instead (defaults to 30 days).
+ *
+ * An empty value can also be set, in order to retain the cookie but remove the block ID
+ * (e.g. as used in User::getBlockedStatus).
+ *
+ * @param WebResponse $response The response on which to set the cookie.
+ * @param boolean $setEmpty Whether to set the cookie's value to the empty string.
+ */
+ public function setCookie( WebResponse $response, $setEmpty = false ) {
+ // Calculate the default expiry time.
+ $config = RequestContext::getMain()->getConfig();
+ $defaultExpiry = wfTimestamp() + $config->get( 'CookieExpiration' );
+
+ // Use the Block's expiry time only if it's less than the default.
+ $expiry = wfTimestamp( TS_UNIX, $this->getExpiry() );
+ if ( $expiry > $defaultExpiry ) {
+ // The *default* default expiry is 30 days.
+ $expiry = $defaultExpiry;
+ }
+
+ $cookieValue = $setEmpty ? '' : $this->getId();
+ $response->setCookie( 'BlockID', $cookieValue, $expiry );
+ }
+
/**
* Get the key and parameters for the corresponding error message.
*
/**
* Default cookie lifetime, in seconds. Setting to 0 makes all cookies session-only.
*/
-$wgCookieExpiration = 180 * 86400;
+$wgCookieExpiration = 30 * 86400;
/**
* Default login cookie lifetime, in seconds. Setting
* calculate the cookie lifetime. As with $wgCookieExpiration, 0 will make
* login cookies session-only.
*/
-$wgExtendedLoginCookieExpiration = null;
+$wgExtendedLoginCookieExpiration = 180 * 86400;
/**
* Set to set an explicit domain on the login cookies eg, "justthis.domain.org"
*/
$wgSessionName = false;
+/**
+ * Whether to set a cookie when a user is autoblocked. Doing so means that a blocked user, even
+ * after logging out and moving to a new IP address, will still be blocked.
+ */
+$wgCookieSetOnAutoblock = false;
+
/** @} */ # end of cookie settings }
/************************************************************************//**
}
function setHeaders() {
- global $wgOut, $wgUser, $wgAjaxEditStash;
+ global $wgOut, $wgUser, $wgAjaxEditStash, $wgCookieSetOnAutoblock;
$wgOut->addModules( 'mediawiki.action.edit' );
+ if ( $wgCookieSetOnAutoblock === true ) {
+ $wgOut->addModules( 'mediawiki.user.blockcookie' );
+ }
$wgOut->addModuleStyles( 'mediawiki.action.edit.styles' );
if ( $wgUser->getOption( 'showtoolbar' ) ) {
*/
use MediaWiki\MediaWikiServices;
use Wikimedia\ScopedCallback;
+use MediaWiki\Logger\LoggerFactory;
/**
* MediaWiki message cache structure version.
*/
protected $mCache;
+ /**
+ * @var bool[] Map of (language code => boolean)
+ */
+ protected $mCacheVolatile = [];
+
/**
* Should mean that database cannot be used, but check
* @var bool $mDisable
protected $mExpiry;
/**
- * Message cache has its own parser which it uses to transform
- * messages.
+ * Message cache has its own parser which it uses to transform messages
+ * @var ParserOptions
*/
- protected $mParserOptions, $mParser;
+ protected $mParserOptions;
+ /** @var Parser */
+ protected $mParser;
/**
* Variable for tracking which variables are already loaded
*/
public static function normalizeKey( $key ) {
global $wgContLang;
+
$lckey = strtr( $key, ' ', '_' );
if ( ord( $lckey ) < 128 ) {
$lckey[0] = strtolower( $lckey[0] );
# Hash of the contents is stored in memcache, to detect if data-center cache
# or local cache goes out of date (e.g. due to replace() on some other server)
list( $hash, $hashVolatile ) = $this->getValidationHash( $code );
+ $this->mCacheVolatile[$code] = $hashVolatile;
# Try the local cache and check against the cluster hash key...
$cache = $this->getLocalCache( $code );
$bigConds[] = 'page_len > ' . intval( $wgMaxMsgCacheEntrySize );
# Load titles for all oversized pages in the MediaWiki namespace
- $res = $dbr->select( 'page', 'page_title', $bigConds, __METHOD__ . "($code)-big" );
+ $res = $dbr->select(
+ 'page',
+ [ 'page_title', 'page_latest' ],
+ $bigConds,
+ __METHOD__ . "($code)-big"
+ );
foreach ( $res as $row ) {
$cache[$row->page_title] = '!TOO BIG';
+ // At least include revision ID so page changes are reflected in the hash
+ $cache['EXCESSIVE'][$row->page_title] = $row->page_latest;
}
# Conditions to load the remaining pages with their contents
* Updates cache as necessary when message page is changed
*
* @param string|bool $title Name of the page changed (false if deleted)
- * @param mixed $text New contents of the page.
+ * @param string|bool $text New contents of the page (false if deleted)
*/
public function replace( $title, $text ) {
global $wgMaxMsgCacheEntrySize, $wgContLang, $wgLanguageCode;
// a self-deadlock. This is safe as no reads happen *directly* in this
// method between getReentrantScopedLock() and load() below. There is
// no risk of data "changing under our feet" for replace().
- $cacheKey = wfMemcKey( 'messages', $code );
- $scopedLock = $this->getReentrantScopedLock( $cacheKey );
+ $scopedLock = $this->getReentrantScopedLock( wfMemcKey( 'messages', $code ) );
+ // Load the messages from the master DB to avoid race conditions
$this->load( $code, self::FOR_UPDATE );
- $titleKey = wfMemcKey( 'messages', 'individual', $title );
+ // Load the new value into the process cache...
if ( $text === false ) {
- // Article was deleted
$this->mCache[$code][$title] = '!NONEXISTENT';
- $this->wanCache->delete( $titleKey );
} elseif ( strlen( $text ) > $wgMaxMsgCacheEntrySize ) {
- // Check for size
$this->mCache[$code][$title] = '!TOO BIG';
- $this->wanCache->set( $titleKey, ' ' . $text, $this->mExpiry );
+ // Pre-fill the individual key cache with the known latest message text
+ $key = $this->wanCache->makeKey( 'messages-big', $this->mCache[$code]['HASH'], $title );
+ $this->wanCache->set( $key, " $text", $this->mExpiry );
} else {
$this->mCache[$code][$title] = ' ' . $text;
- $this->wanCache->delete( $titleKey );
}
-
- // Mark this cache as definitely "latest" (non-volatile) so
- // load() calls do try to refresh the cache with replica DB data
+ // Mark this cache as definitely being "latest" (non-volatile) so
+ // load() calls do not try to refresh the cache with replica DB data
$this->mCache[$code]['LATEST'] = time();
// Update caches if the lock was acquired
if ( $scopedLock ) {
$this->saveToCaches( $this->mCache[$code], 'all', $code );
+ } else {
+ LoggerFactory::getInstance( 'MessageCache' )->error(
+ __METHOD__ . ': could not acquire lock to update {title} ({code})',
+ [ 'title' => $title, 'code' => $code ] );
}
ScopedCallback::consume( $scopedLock );
protected function getValidationHash( $code ) {
$curTTL = null;
$value = $this->wanCache->get(
- wfMemcKey( 'messages', $code, 'hash', 'v1' ),
+ $this->wanCache->makeKey( 'messages', $code, 'hash', 'v1' ),
$curTTL,
[ wfMemcKey( 'messages', $code ) ]
);
- if ( !$value ) {
- // No hash found at all; cache must regenerate to be safe
- $hash = false;
- $expired = true;
- } else {
+ if ( $value ) {
$hash = $value['hash'];
- if ( ( time() - $value['latest'] ) < WANObjectCache::HOLDOFF_TTL ) {
- // Cache was recently updated via replace() and should be up-to-date
+ if ( ( time() - $value['latest'] ) < WANObjectCache::TTL_MINUTE ) {
+ // Cache was recently updated via replace() and should be up-to-date.
+ // That method is only called in the primary datacenter and uses FOR_UPDATE.
+ // Also, it is unlikely that the current datacenter is *now* secondary one.
$expired = false;
} else {
// See if the "check" key was bumped after the hash was generated
$expired = ( $curTTL < 0 );
}
+ } else {
+ // No hash found at all; cache must regenerate to be safe
+ $hash = false;
+ $expired = true;
}
return [ $hash, $expired ];
* Set the md5 used to validate the local disk cache
*
* If $cache has a 'LATEST' UNIX timestamp key, then the hash will not
- * be treated as "volatile" by getValidationHash() for the next few seconds
+ * be treated as "volatile" by getValidationHash() for the next few seconds.
+ * This is triggered when $cache is generated using FOR_UPDATE mode.
*
* @param string $code
* @param array $cache Cached messages with a version
*/
protected function setValidationHash( $code, array $cache ) {
$this->wanCache->set(
- wfMemcKey( 'messages', $code, 'hash', 'v1' ),
+ $this->wanCache->makeKey( 'messages', $code, 'hash', 'v1' ),
[
'hash' => $cache['HASH'],
'latest' => isset( $cache['LATEST'] ) ? $cache['LATEST'] : 0
*/
private function getMessageForLang( $lang, $lckey, $useDB, &$alreadyTried ) {
global $wgContLang;
+
$langcode = $lang->getCode();
// Try checking the database for the requested language
*/
private function getMessagePageName( $langcode, $uckey ) {
global $wgLanguageCode;
+
if ( $langcode === $wgLanguageCode ) {
// Messages created in the content language will not have the /lang extension
return $uckey;
if ( isset( $this->mCache[$code][$title] ) ) {
$entry = $this->mCache[$code][$title];
if ( substr( $entry, 0, 1 ) === ' ' ) {
- // The message exists, so make sure a string
- // is returned.
+ // The message exists, so make sure a string is returned.
return (string)substr( $entry, 1 );
} elseif ( $entry === '!NONEXISTENT' ) {
return false;
}
// Try the individual message cache
- $titleKey = wfMemcKey( 'messages', 'individual', $title );
-
- $curTTL = null;
- $entry = $this->wanCache->get(
- $titleKey,
- $curTTL,
- [ wfMemcKey( 'messages', $code ) ]
- );
- $entry = ( $curTTL >= 0 ) ? $entry : false;
+ $titleKey = $this->wanCache->makeKey( 'messages-big', $this->mCache[$code]['HASH'], $title );
+
+ if ( $this->mCacheVolatile[$code] ) {
+ $entry = false;
+ // Make sure that individual keys respect the WAN cache holdoff period too
+ LoggerFactory::getInstance( 'MessageCache' )->debug(
+ __METHOD__ . ': loading volatile key \'{titleKey}\'',
+ [ 'titleKey' => $titleKey, 'code' => $code ] );
+ } else {
+ $entry = $this->wanCache->get( $titleKey );
+ }
- if ( $entry ) {
+ if ( $entry !== false ) {
if ( substr( $entry, 0, 1 ) === ' ' ) {
$this->mCache[$code][$title] = $entry;
// The message exists, so make sure a string is returned
}
}
- // Try loading it from the database
+ // Try loading the message from the database
$dbr = wfGetDB( DB_REPLICA );
$cacheOpts = Database::getCacheSetOptions( $dbr );
// Use newKnownCurrent() to avoid querying revision/user tables
if ( $revision ) {
$content = $revision->getContent();
- if ( !$content ) {
- // A possibly temporary loading failure.
- wfDebugLog(
- 'MessageCache',
- __METHOD__ . ": failed to load message page text for {$title} ($code)"
- );
- $message = null; // no negative caching
- } else {
- // XXX: Is this the right way to turn a Content object into a message?
- // NOTE: $content is typically either WikitextContent, JavaScriptContent or
- // CssContent. MessageContent is *not* used for storing messages, it's
- // only used for wrapping them when needed.
- $message = $content->getWikitextForTransclusion();
-
- if ( $message === false || $message === null ) {
- wfDebugLog(
- 'MessageCache',
- __METHOD__ . ": message content doesn't provide wikitext "
- . "(content model: " . $content->getModel() . ")"
- );
-
- $message = false; // negative caching
- } else {
+ if ( $content ) {
+ $message = $this->getMessageTextFromContent( $content );
+ if ( is_string( $message ) ) {
$this->mCache[$code][$title] = ' ' . $message;
$this->wanCache->set( $titleKey, ' ' . $message, $this->mExpiry, $cacheOpts );
}
+ } else {
+ // A possibly temporary loading failure
+ LoggerFactory::getInstance( 'MessageCache' )->warning(
+ __METHOD__ . ': failed to load message page text for \'{titleKey}\'',
+ [ 'titleKey' => $titleKey, 'code' => $code ] );
+ $message = null; // no negative caching
}
} else {
$message = false; // negative caching
*/
function getParser() {
global $wgParser, $wgParserConf;
+
if ( !$this->mParser && isset( $wgParser ) ) {
# Do some initialisation so that we don't have to do it twice
$wgParser->firstCallInit();
public function parse( $text, $title = null, $linestart = true,
$interface = false, $language = null
) {
+ global $wgTitle;
+
if ( $this->mInParser ) {
return htmlspecialchars( $text );
}
$popts->setTargetLanguage( $language );
if ( !$title || !$title instanceof Title ) {
- global $wgTitle;
wfDebugLog( 'GlobalTitleFail', __METHOD__ . ' called by ' .
wfGetAllCallers( 6 ) . ' with no title set.' );
$title = $wgTitle;
*/
public function getAllMessageKeys( $code ) {
global $wgContLang;
+
$this->load( $code );
if ( !isset( $this->mCache[$code] ) ) {
// Apparently load() failed
$cache = $this->mCache[$code];
unset( $cache['VERSION'] );
unset( $cache['EXPIRY'] );
+ unset( $cache['EXCESSIVE'] );
// Remove any !NONEXISTENT keys
$cache = array_diff( $cache, [ '!NONEXISTENT' ] );
// Keys may appear with a capital first letter. lcfirst them.
return array_map( [ $wgContLang, 'lcfirst' ], array_keys( $cache ) );
}
+
+ /**
+ * Purge message caches when a MediaWiki: page is created, updated, or deleted
+ *
+ * @param Title $title Message page title
+ * @param Content|null $content New content for edit/create, null on deletion
+ * @since 1.29
+ */
+ public function updateMessageOverride( Title $title, Content $content = null ) {
+ global $wgContLang;
+
+ $msgText = $this->getMessageTextFromContent( $content );
+ if ( $msgText === null ) {
+ $msgText = false; // treat as not existing
+ }
+
+ $this->replace( $title->getDBkey(), $msgText );
+
+ if ( $wgContLang->hasVariants() ) {
+ $wgContLang->updateConversionTable( $title );
+ }
+ }
+
+ /**
+ * @param Content|null $content Content or null if the message page does not exist
+ * @return string|bool|null Returns false if $content is null and null on error
+ */
+ private function getMessageTextFromContent( Content $content = null ) {
+ // @TODO: could skip pseudo-messages like js/css here, based on content model
+ if ( $content ) {
+ // Message page exists...
+ // XXX: Is this the right way to turn a Content object into a message?
+ // NOTE: $content is typically either WikitextContent, JavaScriptContent or
+ // CssContent. MessageContent is *not* used for storing messages, it's
+ // only used for wrapping them when needed.
+ $msgText = $content->getWikitextForTransclusion();
+ if ( $msgText === false || $msgText === null ) {
+ // This might be due to some kind of misconfiguration...
+ $msgText = null;
+ LoggerFactory::getInstance( 'MessageCache' )->warning(
+ __METHOD__ . ": message content doesn't provide wikitext "
+ . "(content model: " . $content->getModel() . ")" );
+ }
+ } else {
+ // Message page does not exist...
+ $msgText = false;
+ }
+
+ return $msgText;
+ }
}
* @return Collation
*/
public static function factory( $collationName ) {
+ global $wgContLang;
+
switch ( $collationName ) {
case 'uppercase':
return new UppercaseCollation;
case 'numeric':
- return new NumericUppercaseCollation;
+ return new NumericUppercaseCollation( $wgContLang );
case 'identity':
return new IdentityCollation;
case 'uca-default':
'be' => [ "Ё" ],
'be-tarask' => [ "Ё" ],
'bg' => [],
+ 'bn' => [ 'ং', 'ঃ', 'ঁ' ],
+ 'bn@collation=traditional' => [
+ 'ং', 'ঃ', 'ঁ', 'ক্', 'খ্', 'গ্', 'ঘ্', 'ঙ্', 'চ্', 'ছ্', 'জ্', 'ঝ্',
+ 'ঞ্', 'ট্', 'ঠ্', 'ড্', 'ঢ্', 'ণ্', 'ৎ', 'থ্', 'দ্', 'ধ্', 'ন্', 'প্',
+ 'ফ্', 'ব্', 'ভ্', 'ম্', 'য্', 'র্', 'ৰ্', 'ল্', 'ৱ্', 'শ্', 'ষ্', 'স্', 'হ্'
+ ],
'bo' => [],
'br' => [ "Ch", "C'h" ],
'bs' => [ "Č", "Ć", "Dž", "Đ", "Lj", "Nj", "Š", "Ž" ],
* Note that this only works in terms of sequences of digits, and the behavior for decimal fractions
* or pretty-formatted numbers may be unexpected.
*
+ * Digits will be based on the wiki's content language settings. If
+ * you change the content langauge of a wiki you will need to run
+ * updateCollation.php --force. Only English (ASCII 0-9) and the
+ * localized version will be counted. Localized digits from other languages
+ * or weird unicode digit equivalents (e.g. 4, 𝟜, ⓸ , ⁴, etc) will not count.
+ *
* @since 1.28
*/
class NumericUppercaseCollation extends UppercaseCollation {
+
+ /**
+ * @var $digitTransformLang Language How to convert digits (usually $wgContLang)
+ */
+ private $digitTransformLang;
+
+ /**
+ * Constructor
+ *
+ * @param $lang Language How to convert digits.
+ * For example, if given language "my" than ၇ is treated like 7.
+ *
+ * It is expected that usually this is given $wgContLang.
+ */
+ public function __construct( Language $lang ) {
+ $this->digitTransformLang = $lang;
+ parent::__construct();
+ }
+
public function getSortKey( $string ) {
$sortkey = parent::getSortKey( $string );
-
+ $sortkey = $this->convertDigits( $sortkey );
// For each sequence of digits, insert the digit '0' and then the length of the sequence
// (encoded in two bytes) before it. That's all folks, it sorts correctly now! The '0' ensures
// correct position (where digits would normally sort), then the length will be compared putting
return $sortkey;
}
+ /**
+ * Convert localized digits to english digits.
+ *
+ * based on Language::parseFormattedNumber but without commas.
+ *
+ * @param $string String sortkey to unlocalize digits of
+ * @return String Sortkey with all localized digits replaced with ASCII digits.
+ */
+ private function convertDigits( $string ) {
+ $table = $this->digitTransformLang->digitTransformTable();
+ if ( $table ) {
+ $table = array_filter( $table );
+ $flipped = array_flip( $table );
+ // Some languages seem to also have commas in this table.
+ $flipped = array_filter( $flipped, 'is_numeric' );
+ $string = strtr( $string, $flipped );
+ }
+ return $string;
+ }
+
public function getFirstLetter( $string ) {
- if ( preg_match( '/^\d/', $string ) ) {
- // Note that we pass 0 and 9 as normal params, not numParams(). This only works for 0-9
- // and not localised digits, so we don't want them to be converted.
- return wfMessage( 'category-header-numerals' )->params( 0, 9 )->text();
+ $convertedString = $this->convertDigits( $string );
+
+ if ( preg_match( '/^\d/', $convertedString ) ) {
+ return wfMessage( 'category-header-numerals' )
+ ->numParams( 0, 9 )
+ ->text();
} else {
return parent::getFirstLetter( $string );
}
*/
class WinCacheBagOStuff extends BagOStuff {
protected function doGet( $key, $flags = 0 ) {
- $casToken = null;
-
- return $this->getWithToken( $key, $casToken, $flags );
- }
-
- protected function getWithToken( $key, &$casToken, $flags = 0 ) {
$val = wincache_ucache_get( $key );
-
- $casToken = $val;
-
if ( is_string( $val ) ) {
$val = unserialize( $val );
}
return ( is_array( $result ) && $result === [] ) || $result;
}
- protected function cas( $casToken, $key, $value, $exptime = 0 ) {
- return wincache_ucache_cas( $key, $casToken, serialize( $value ) );
- }
-
public function delete( $key ) {
wincache_ucache_delete( $key );
}
if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) {
- // @todo move this logic to MessageCache
- if ( $this->exists() ) {
- // NOTE: use transclusion text for messages.
- // This is consistent with MessageCache::getMsgFromNamespace()
-
- $content = $this->getContent();
- $text = $content === null ? null : $content->getWikitextForTransclusion();
-
- if ( $text === null ) {
- $text = false;
- }
- } else {
- $text = false;
- }
-
- MessageCache::singleton()->replace( $this->mTitle->getDBkey(), $text );
+ $messageCache = MessageCache::singleton();
+ $messageCache->updateMessageOverride( $this->mTitle, $this->getContent() );
}
return true;
* - 'no-change': don't update the article count, ever
*/
public function doEditUpdates( Revision $revision, User $user, array $options = [] ) {
- global $wgRCWatchCategoryMembership, $wgContLang;
+ global $wgRCWatchCategoryMembership;
$options += [
'changed' => true,
}
if ( $this->mTitle->getNamespace() == NS_MEDIAWIKI ) {
- // XXX: could skip pseudo-messages like js/css here, based on content model.
- $msgtext = $content ? $content->getWikitextForTransclusion() : null;
- if ( $msgtext === false || $msgtext === null ) {
- $msgtext = '';
- }
-
- MessageCache::singleton()->replace( $shortTitle, $msgtext );
-
- if ( $wgContLang->hasVariants() ) {
- $wgContLang->updateConversionTable( $this->mTitle );
- }
+ MessageCache::singleton()->updateMessageOverride( $this->mTitle, $content );
}
if ( $options['created'] ) {
* @param Title $title
*/
public static function onArticleDelete( Title $title ) {
- global $wgContLang;
-
// Update existence markers on article/talk tabs...
$other = $title->getOtherPage();
// Messages
if ( $title->getNamespace() == NS_MEDIAWIKI ) {
- MessageCache::singleton()->replace( $title->getDBkey(), false );
-
- if ( $wgContLang->hasVariants() ) {
- $wgContLang->updateConversionTable( $title );
- }
+ MessageCache::singleton()->updateMessageOverride( $title, null );
}
// Images
$user = $session->getUser();
if ( $user->isLoggedIn() ) {
$this->loadFromUserObject( $user );
+
+ // If this user is autoblocked, set a cookie to track the Block. This has to be done on
+ // every session load, because an autoblocked editor might not edit again from the same
+ // IP address after being blocked.
+ $config = RequestContext::getMain()->getConfig();
+ if ( $config->get( 'CookieSetOnAutoblock' ) === true ) {
+ $block = $this->getBlock();
+ $shouldSetCookie = $this->getRequest()->getCookie( 'BlockID' ) === null
+ && $block
+ && $block->getType() === Block::TYPE_USER
+ && $block->isAutoblocking();
+ if ( $shouldSetCookie ) {
+ wfDebug( __METHOD__ . ': User is autoblocked, setting cookie to track' );
+ $block->setCookie( $this->getRequest()->response() );
+ }
+ }
+
// Other code expects these to be set in the session, so set them.
$session->set( 'wsUserID', $this->getId() );
$session->set( 'wsUserName', $this->getName() );
$session->set( 'wsToken', $this->getToken() );
return true;
}
-
return false;
}
// User/IP blocking
$block = Block::newFromTarget( $this, $ip, !$bFromSlave );
+ // If no block has been found, check for a cookie indicating that the user is blocked.
+ $blockCookieVal = (int)$this->getRequest()->getCookie( 'BlockID' );
+ if ( !$block instanceof Block && $blockCookieVal > 0 ) {
+ // Load the Block from the ID in the cookie.
+ $tmpBlock = Block::newFromID( $blockCookieVal );
+ if ( $tmpBlock instanceof Block ) {
+ // Check the validity of the block.
+ $blockIsValid = $tmpBlock->getType() == Block::TYPE_USER
+ && !$tmpBlock->isExpired()
+ && $tmpBlock->isAutoblocking();
+ $config = RequestContext::getMain()->getConfig();
+ $useBlockCookie = ( $config->get( 'CookieSetOnAutoblock' ) === true );
+ if ( $blockIsValid && $useBlockCookie ) {
+ // Use the block.
+ $block = $tmpBlock;
+ } else {
+ // If the block is not valid, clear the block cookie (but don't delete it,
+ // because it needs to be cleared from LocalStorage as well and an empty string
+ // value is checked for in the mediawiki.user.blockcookie module).
+ $block->setCookie( $this->getRequest()->response(), true );
+ }
+ }
+ }
+
// Proxy blocking
if ( !$block instanceof Block && $ip !== null && !in_array( $ip, $wgProxyWhitelist ) ) {
// Local list
class PurgeParserCache extends Maintenance {
public $lastProgress;
+ private $usleep = 0;
+
function __construct() {
parent::__construct();
$this->addDescription( "Remove old objects from the parser cache. " .
$this->addOption( 'expiredate', 'Delete objects expiring before this date.', false, true );
$this->addOption(
'age',
- 'Delete objects created more than this many seconds ago, assuming $wgParserCacheExpireTime ' .
- 'has been consistent.',
- false, true );
+ 'Delete objects created more than this many seconds ago, assuming ' .
+ '$wgParserCacheExpireTime has remained consistent.',
+ false,
+ true );
+ $this->addOption( 'msleep', 'Milliseconds to sleep between purge chunks', false, true );
}
function execute() {
+ global $wgParserCacheExpireTime;
+
$inputDate = $this->getOption( 'expiredate' );
$inputAge = $this->getOption( 'age' );
if ( $inputDate !== null ) {
$date = wfTimestamp( TS_MW, strtotime( $inputDate ) );
} elseif ( $inputAge !== null ) {
- global $wgParserCacheExpireTime;
$date = wfTimestamp( TS_MW, time() + $wgParserCacheExpireTime - intval( $inputAge ) );
} else {
$this->error( "Must specify either --expiredate or --age", 1 );
+ return;
}
+ $this->usleep = 1e3 * $this->getOption( 'msleep', 0 );
$english = Language::factory( 'en' );
- $this->output( "Deleting objects expiring before " . $english->timeanddate( $date ) . "\n" );
+ $this->output( "Deleting objects expiring before " .
+ $english->timeanddate( $date ) . "\n" );
$pc = wfGetParserCacheStorage();
- $success = $pc->deleteObjectsExpiringBefore( $date, [ $this, 'showProgress' ] );
+ $success = $pc->deleteObjectsExpiringBefore( $date, [ $this, 'showProgressAndWait' ] );
if ( !$success ) {
$this->error( "\nCannot purge this kind of parser cache.", 1 );
}
- $this->showProgress( 100 );
+ $this->showProgressAndWait( 100 );
$this->output( "\nDone\n" );
}
- function showProgress( $percent ) {
+ public function showProgressAndWait( $percent ) {
+ usleep( $this->usleep ); // avoid lag; T150124
+
$percentString = sprintf( "%.2f", $percent );
if ( $percentString === $this->lastProgress ) {
return;
'dependencies' => 'mediawiki.util',
'targets' => [ 'desktop', 'mobile' ],
],
+ 'mediawiki.user.blockcookie' => [
+ 'scripts' => 'resources/src/mediawiki/mediawiki.user.blockcookie.js',
+ 'dependencies' => [ 'mediawiki.cookie', 'mediawiki.storage' ],
+ 'targets' => [ 'desktop', 'mobile' ],
+ ],
'mediawiki.user' => [
'scripts' => 'resources/src/mediawiki/mediawiki.user.js',
'dependencies' => [
if ( file && file.type === 'image/jpeg' ) {
fileReader = new FileReader();
fileReader.onload = function () {
- // TODO: fileStr is never used.
var fileStr, arr, i, metadata;
if ( typeof fileReader.result === 'string' ) {
--- /dev/null
+( function ( mw ) {
+
+ // If a user has been autoblocked, a cookie is set.
+ // Its value is replicated here in localStorage to guard against cookie-removal.
+ // This module will only be loaded when $wgCookieSetOnAutoblock is true.
+ // Ref: https://phabricator.wikimedia.org/T5233
+
+ if ( !mw.cookie.get( 'BlockID' ) && mw.storage.get( 'blockID' ) ) {
+ // The block ID exists in storage, but not in the cookie.
+ mw.cookie.set( 'BlockID', mw.storage.get( 'blockID' ) );
+
+ } else if ( parseInt( mw.cookie.get( 'BlockID' ), 10 ) > 0 && !mw.storage.get( 'blockID' ) ) {
+ // The block ID exists in the cookie, but not in storage.
+ // (When a block expires the cookie remains but its value is '', hence the integer check above.)
+ mw.storage.set( 'blockID', mw.cookie.get( 'BlockID' ) );
+
+ } else if ( mw.cookie.get( 'BlockID' ) === '' && mw.storage.get( 'blockID' ) ) {
+ // If only the empty string is in the cookie, remove the storage value. The block is no longer valid.
+ mw.storage.remove( 'blockID' );
+
+ }
+
+}( mediaWiki ) );
$users->rewind();
$this->assertTrue( $user->equals( $users->current() ) );
}
+
+ /**
+ * When a user is autoblocked a cookie is set with which to track them
+ * in case they log out and change IP addresses.
+ * @link https://phabricator.wikimedia.org/T5233
+ */
+ public function testAutoblockCookies() {
+ // Set up the bits of global configuration that we use.
+ $this->setMwGlobals( [
+ 'wgCookieSetOnAutoblock' => true,
+ 'wgCookiePrefix' => 'wmsitetitle',
+ ] );
+
+ // 1. Log in a test user, and block them.
+ $user1tmp = $this->getTestUser()->getUser();
+ $request1 = new FauxRequest();
+ $request1->getSession()->setUser( $user1tmp );
+ $expiryFiveDays = time() + ( 5 * 24 * 60 * 60 );
+ $block = new Block( [
+ 'enableAutoblock' => true,
+ 'expiry' => wfTimestamp( TS_MW, $expiryFiveDays ),
+ ] );
+ $block->setTarget( $user1tmp );
+ $block->insert();
+ $user1 = User::newFromSession( $request1 );
+ $user1->mBlock = $block;
+ $user1->load();
+
+ // Confirm that the block has been applied as required.
+ $this->assertTrue( $user1->isLoggedIn() );
+ $this->assertTrue( $user1->isBlocked() );
+ $this->assertEquals( Block::TYPE_USER, $block->getType() );
+ $this->assertTrue( $block->isAutoblocking() );
+ $this->assertGreaterThanOrEqual( 1, $block->getId() );
+
+ // Test for the desired cookie name, value, and expiry.
+ $cookies = $request1->response()->getCookies();
+ $this->assertArrayHasKey( 'wmsitetitleBlockID', $cookies );
+ $this->assertEquals( $block->getId(), $cookies['wmsitetitleBlockID']['value'] );
+ $this->assertEquals( $expiryFiveDays, $cookies['wmsitetitleBlockID']['expire'] );
+
+ // 2. Create a new request, set the cookies, and see if the (anon) user is blocked.
+ $request2 = new FauxRequest();
+ $request2->setCookie( 'BlockID', $block->getId() );
+ $user2 = User::newFromSession( $request2 );
+ $user2->load();
+ $this->assertNotEquals( $user1->getId(), $user2->getId() );
+ $this->assertNotEquals( $user1->getToken(), $user2->getToken() );
+ $this->assertTrue( $user2->isAnon() );
+ $this->assertFalse( $user2->isLoggedIn() );
+ $this->assertTrue( $user2->isBlocked() );
+ $this->assertEquals( true, $user2->getBlock()->isAutoblocking() ); // Non-strict type-check.
+ // Can't directly compare the objects becuase of member type differences.
+ // One day this will work: $this->assertEquals( $block, $user2->getBlock() );
+ $this->assertEquals( $block->getId(), $user2->getBlock()->getId() );
+ $this->assertEquals( $block->getExpiry(), $user2->getBlock()->getExpiry() );
+
+ // 3. Finally, set up a request as a new user, and the block should still be applied.
+ $user3tmp = $this->getTestUser()->getUser();
+ $request3 = new FauxRequest();
+ $request3->getSession()->setUser( $user3tmp );
+ $request3->setCookie( 'BlockID', $block->getId() );
+ $user3 = User::newFromSession( $request3 );
+ $user3->load();
+ $this->assertTrue( $user3->isLoggedIn() );
+ $this->assertTrue( $user3->isBlocked() );
+ $this->assertEquals( true, $user3->getBlock()->isAutoblocking() ); // Non-strict type-check.
+
+ // Clean up.
+ $block->delete();
+ }
+
+ /**
+ * Make sure that no cookie is set to track autoblocked users
+ * when $wgCookieSetOnAutoblock is false.
+ */
+ public function testAutoblockCookiesDisabled() {
+ // Set up the bits of global configuration that we use.
+ $this->setMwGlobals( [
+ 'wgCookieSetOnAutoblock' => false,
+ 'wgCookiePrefix' => 'wm_no_cookies',
+ ] );
+
+ // 1. Log in a test user, and block them.
+ $testUser = $this->getTestUser()->getUser();
+ $request1 = new FauxRequest();
+ $request1->getSession()->setUser( $testUser );
+ $block = new Block( [ 'enableAutoblock' => true ] );
+ $block->setTarget( $testUser );
+ $block->insert();
+ $user = User::newFromSession( $request1 );
+ $user->mBlock = $block;
+ $user->load();
+
+ // 2. Test that the cookie IS NOT present.
+ $this->assertTrue( $user->isLoggedIn() );
+ $this->assertTrue( $user->isBlocked() );
+ $this->assertEquals( Block::TYPE_USER, $block->getType() );
+ $this->assertTrue( $block->isAutoblocking() );
+ $this->assertGreaterThanOrEqual( 1, $user->getBlockId() );
+ $this->assertGreaterThanOrEqual( $block->getId(), $user->getBlockId() );
+ $cookies = $request1->response()->getCookies();
+ $this->assertArrayNotHasKey( 'wm_no_cookiesBlockID', $cookies );
+
+ // Clean up.
+ $block->delete();
+ }
+
+ /**
+ * When a user is autoblocked and a cookie is set to track them, the expiry time of the cookie
+ * should match the block's expiry. If the block is infinite, the cookie expiry time should
+ * match $wgCookieExpiration. If the expiry time is changed, the cookie's should change with it.
+ */
+ public function testAutoblockCookieInfiniteExpiry() {
+ $cookieExpiration = 20 * 24 * 60 * 60; // 20 days
+ $this->setMwGlobals( [
+ 'wgCookieSetOnAutoblock' => true,
+ 'wgCookieExpiration' => $cookieExpiration,
+ 'wgCookiePrefix' => 'wm_infinite_block',
+ ] );
+ // 1. Log in a test user, and block them indefinitely.
+ $user1Tmp = $this->getTestUser()->getUser();
+ $request1 = new FauxRequest();
+ $request1->getSession()->setUser( $user1Tmp );
+ $block = new Block( [ 'enableAutoblock' => true, 'expiry' => 'infinity' ] );
+ $block->setTarget( $user1Tmp );
+ $block->insert();
+ $user1 = User::newFromSession( $request1 );
+ $user1->mBlock = $block;
+ $user1->load();
+
+ // 2. Test the cookie's expiry timestamp.
+ $this->assertTrue( $user1->isLoggedIn() );
+ $this->assertTrue( $user1->isBlocked() );
+ $this->assertEquals( Block::TYPE_USER, $block->getType() );
+ $this->assertTrue( $block->isAutoblocking() );
+ $this->assertGreaterThanOrEqual( 1, $user1->getBlockId() );
+ $cookies = $request1->response()->getCookies();
+ // Calculate the expected cookie expiry date.
+ $this->assertArrayHasKey( 'wm_infinite_blockBlockID', $cookies );
+ $this->assertEquals( time() + $cookieExpiration, $cookies['wm_infinite_blockBlockID']['expire'] );
+
+ // 3. Change the block's expiry (to 2 days), and the cookie's should be changed also.
+ $newExpiry = time() + 2 * 24 * 60 * 60;
+ $block->mExpiry = wfTimestamp( TS_MW, $newExpiry );
+ $block->update();
+ $user2tmp = $this->getTestUser()->getUser();
+ $request2 = new FauxRequest();
+ $request2->getSession()->setUser( $user2tmp );
+ $user2 = User::newFromSession( $request2 );
+ $user2->mBlock = $block;
+ $user2->load();
+ $cookies = $request2->response()->getCookies();
+ $this->assertEquals( $newExpiry, $cookies['wm_infinite_blockBlockID']['expire'] );
+
+ // Clean up.
+ $block->delete();
+ }
}