* (bug 674) Allow users to be blocked from editing a specific article
[lhc/web/wiklou.git] / includes / Title.php
index 349bce7..0bef7bc 100644 (file)
@@ -10,12 +10,6 @@ if ( !class_exists( 'UtfNormal' ) ) {
 
 define ( 'GAID_FOR_UPDATE', 1 );
 
-/**
- * Title::newFromText maintains a cache to avoid expensive re-normalization of
- * commonly used titles. On a batch operation this can become a memory leak
- * if not bounded. After hitting this many titles reset the cache.
- */
-define( 'MW_TITLECACHE_MAX', 1000 );
 
 /**
  * Constants for pr_cascade bitfield
@@ -35,6 +29,14 @@ class Title {
        static private $interwikiCache=array();
        //@}
 
+       /**
+        * Title::newFromText maintains a cache to avoid expensive re-normalization of
+        * commonly used titles. On a batch operation this can become a memory leak
+        * if not bounded. After hitting this many titles reset the cache.
+        */
+       const CACHE_MAX = 1000;
+
+
        /**
         * @name Private member variables
         * Please use the accessor functions instead.
@@ -54,9 +56,9 @@ class Title {
        var $mRestrictions = array();     ///< Array of groups allowed to edit this article
        var $mOldRestrictions = false;
        var $mCascadeRestriction;         ///< Cascade restrictions on this page to included templates and images?
-       var $mRestrictionsExpiry;         ///< When do the restrictions on this page expire?
+       var $mRestrictionsExpiry = array();       ///< When do the restrictions on this page expire?
        var $mHasCascadingRestrictions;   ///< Are cascading restrictions in effect on this page?
-       var $mCascadeRestrictionSources;  ///< Where are the cascading restrictions coming from on this page?
+       var $mCascadeSources;  ///< Where are the cascading restrictions coming from on this page?
        var $mRestrictionsLoaded = false; ///< Boolean for initialisation on demand
        var $mPrefixedText;               ///< Text form including namespace/interwiki, initialised on demand
        # Don't change the following default, NS_MAIN is hardcoded in several
@@ -131,7 +133,7 @@ class Title {
                static $cachedcount = 0 ;
                if( $t->secureAndSplit() ) {
                        if( $defaultNamespace == NS_MAIN ) {
-                               if( $cachedcount >= MW_TITLECACHE_MAX ) {
+                               if( $cachedcount >= self::CACHE_MAX ) {
                                        # Avoid memory leaks on mass operations...
                                        Title::$titleCache = array();
                                        $cachedcount=0;
@@ -196,8 +198,8 @@ class Title {
 
        /**
         * Make an array of titles from an array of IDs
-        * @param $ids \arrayof{\int} Array of IDs
-        * @return \arrayof{Title} Array of Titles
+        * @param $ids \type{\arrayof{\int}} Array of IDs
+        * @return \type{\arrayof{Title}} Array of Titles
         */
        public static function newFromIDs( $ids ) {
                if ( !count( $ids ) ) {
@@ -408,6 +410,12 @@ class Title {
                global $wgInterwikiCache, $wgContLang;
                $fname = 'Title::getInterwikiLink';
 
+               if ( count( Title::$interwikiCache ) >= self::CACHE_MAX ) {
+                       // Don't use infinite memory
+                       reset( Title::$interwikiCache );
+                       unset( Title::$interwikiCache[ key( Title::$interwikiCache ) ] );
+               }
+
                $key = $wgContLang->lc( $key );
 
                $k = wfMemcKey( 'interwiki', $key );
@@ -801,7 +809,7 @@ class Title {
         */
        public function getLocalURL( $query = '', $variant = false ) {
                global $wgArticlePath, $wgScript, $wgServer, $wgRequest;
-               global $wgVariantArticlePath, $wgContLang, $wgUser;
+               global $wgVariantArticlePath, $wgContLang, $wgUser, $wgArticlePathForCurid;
 
                if( is_array( $query ) ) {
                        $query = wfArrayToCGI( $query );
@@ -825,7 +833,7 @@ class Title {
                        }
                } else {
                        $dbkey = wfUrlencode( $this->getPrefixedDBkey() );
-                       if ( $query == '' ) {
+                       if ( $query == '' || ($wgArticlePathForCurid && substr_count( $query, '&' ) == 0 && strpos( $query, 'curid=' ) === 0 ) ) {
                                if( $variant != false && $wgContLang->hasVariants() ) {
                                        if( $wgVariantArticlePath == false ) {
                                                $variantArticlePath =  "$wgScript?title=$1&variant=$2"; // default
@@ -837,6 +845,7 @@ class Title {
                                } else {
                                        $url = str_replace( '$1', $dbkey, $wgArticlePath );
                                }
+                               $url = wfAppendQuery( $url, $query );
                        } else {
                                global $wgActionPaths;
                                $url = false;
@@ -877,7 +886,7 @@ class Title {
         * there's a fragment but the prefixed text is empty, we just return a link
         * to the fragment.
         *
-        * @param $query \arrayof{\string} An associative array of key => value pairs for the
+        * @param $query \type{\arrayof{\string}} An associative array of key => value pairs for the
         *   query string.  Keys and values will be escaped.
         * @param $variant \type{\string} Language variant of URL (for sr, zh..).  Ignored
         *   for external links.  Default is "false" (same variant as current page,
@@ -1069,7 +1078,7 @@ class Title {
 
        /**
         * Can $wgUser perform $action on this page?
-        * @param \type{\string} $action action that permission needs to be checked for
+        * @param $action \type{\string} action that permission needs to be checked for
         * @param $doExpensiveQueries \type{\bool} Set this to false to avoid doing unnecessary queries.
         * @return \type{\bool}
         */
@@ -1086,7 +1095,7 @@ class Title {
         * @param $action \type{\string}action that permission needs to be checked for
         * @param $user \type{User} user to check
         * @param $doExpensiveQueries \type{\bool} Set this to false to avoid doing unnecessary queries.
-        * @param $ignoreErrors \arrayof{\string} Set this to a list of message keys whose corresponding errors may be ignored.
+        * @param $ignoreErrors \type{\arrayof{\string}} Set this to a list of message keys whose corresponding errors may be ignored.
         * @return \type{\array} Array of arrays of the arguments to wfMsg to explain permissions problems.
         */
        public function getUserPermissionsErrors( $action, $user, $doExpensiveQueries = true, $ignoreErrors = array() ) {
@@ -1098,15 +1107,14 @@ class Title {
                }
                $errors = $this->getUserPermissionsErrorsInternal( $action, $user, $doExpensiveQueries );
 
-               global $wgContLang;
-               global $wgLang;
-               global $wgEmailConfirmToEdit;
+               global $wgContLang, $wgLang, $wgEmailConfirmToEdit;
 
                if ( $wgEmailConfirmToEdit && !$user->isEmailConfirmed() && $action != 'createaccount' ) {
                        $errors[] = array( 'confirmedittext' );
                }
 
-               if ( $user->isBlockedFrom( $this ) && $action != 'createaccount' ) {
+               // Edit blocks should not affect reading. Account creation blocks handled at userlogin.
+               if ( $user->isBlockedFrom( $this ) && $action != 'read' && $action != 'createaccount' ) {
                        $block = $user->mBlock;
 
                        // This is from OutputPage::blockedPage
@@ -1131,20 +1139,7 @@ class Title {
                        $blockTimestamp = $wgLang->timeanddate( wfTimestamp( TS_MW, $user->mBlock->mTimestamp ), true );
 
                        if ( $blockExpiry == 'infinity' ) {
-                               // Entry in database (table ipblocks) is 'infinity' but 'ipboptions' uses 'infinite' or 'indefinite'
-                               $scBlockExpiryOptions = wfMsg( 'ipboptions' );
-
-                               foreach ( explode( ',', $scBlockExpiryOptions ) as $option ) {
-                                       if ( strpos( $option, ':' ) == false )
-                                               continue;
-
-                                       list ($show, $value) = explode( ":", $option );
-
-                                       if ( $value == 'infinite' || $value == 'indefinite' ) {
-                                               $blockExpiry = $show;
-                                               break;
-                                       }
-                               }
+                               $blockExpiry = wfMsg( 'ipbinfinite' );
                        } else {
                                $blockExpiry = $wgLang->timeanddate( wfTimestamp( TS_MW, $blockExpiry ), true );
                        }
@@ -1154,9 +1149,9 @@ class Title {
                        $errors[] = array( ($block->mAuto ? 'autoblockedtext' : 'blockedtext'), $link, $reason, $ip, $name, 
                                $blockid, $blockExpiry, $intended, $blockTimestamp );
                }
-               
+
                // Remove the errors being ignored.
-               
+
                foreach( $errors as $index => $error ) {
                        $error_key = is_array($error) ? $error[0] : $error;
                        
@@ -1179,6 +1174,8 @@ class Title {
         * @return \type{\array} Array of arrays of the arguments to wfMsg to explain permissions problems.
         */
        private function getUserPermissionsErrorsInternal( $action, $user, $doExpensiveQueries = true ) {
+               global $wgLang;
+
                wfProfileIn( __METHOD__ );
 
                $errors = array();
@@ -1324,6 +1321,26 @@ class Title {
                        $errors[] = $return;
                }
 
+               // Check per-user restrictions
+               if( $action != 'read' ) {
+                       $r = $user->getRestrictionForPage( $this );
+                       if( !$r )
+                               $r = $user->getRestrictionForNamespace( $this->getNamespace() );
+                       if( $r ) {
+                               $start = $wgLang->timeanddate( $r->getTimestamp() );
+                               $end = $r->getExpiry() == 'infinity' ?
+                                       wfMsg( 'ipbinfinite' ) :
+                                       $wgLang->timeanddate( $r->getExpiry() );
+                               if( $r->isPage() )
+                                       $errors[] = array( 'userrestricted-page', $this->getFullText(),
+                                               $r->getBlockerText(), $r->getReason(), $start, $end );
+                               elseif( $r->isNamespace() ) {
+                                       $errors[] = array( 'userrestricted-namespace', $wgLang->getDisplayNsText( $this->getNamespace() ),
+                                               $r->getBlockerText(), $r->getReason(), $start, $end );
+                               }
+                       }
+               }
+
                wfProfileOut( __METHOD__ );
                return $errors;
        }
@@ -1360,7 +1377,7 @@ class Title {
                global $wgUser,$wgContLang;
 
                if ($create_perm == implode(',',$this->getRestrictions('create'))
-                       && $expiry == $this->mRestrictionsExpiry) {
+                       && $expiry == $this->mRestrictionsExpiry['create']) {
                        // No change
                        return true;
                }
@@ -1375,7 +1392,10 @@ class Title {
                if ( $encodedExpiry != 'infinity' ) {
                        $expiry_description = ' (' . wfMsgForContent( 'protect-expiring', $wgContLang->timeanddate( $expiry ) ).')';
                }
-
+               else {
+                       $expiry_description .= ' (' . wfMsgForContent( 'protect-expiry-indefinite' ).')';
+               }
+       
                # Update protection table
                if ($create_perm != '' ) {
                        $dbw->replace( 'protected_titles', array(array('pt_namespace', 'pt_title')),
@@ -1392,7 +1412,8 @@ class Title {
                $log = new LogPage( 'protect' );
 
                if( $create_perm ) {
-                       $log->addEntry( $this->mRestrictions['create'] ? 'modify' : 'protect', $this, trim( $reason . " [create=$create_perm] $expiry_description" ) );
+                       $params = array("[create=$create_perm] $expiry_description",'');
+                       $log->addEntry( $this->mRestrictions['create'] ? 'modify' : 'protect', $this, trim( $reason ), $params );
                } else {
                        $log->addEntry( 'unprotect', $this, $reason );
                }
@@ -1648,7 +1669,7 @@ class Title {
         * Cascading protection: Get the source of any cascading restrictions on this page.
         *
         * @param $get_pages \type{\bool} Whether or not to retrieve the actual pages that the restrictions have come from.
-        * @return \arrayof{mixed title array, restriction array} Array of the Title objects of the pages from 
+        * @return \type{\arrayof{mixed title array, restriction array}} Array of the Title objects of the pages from 
         *         which cascading restrictions have come, false for none, or true if such restrictions exist, but $get_pages was not set.
         *         The restriction array is an array of each type, each of which contains an array of unique groups.
         */
@@ -1732,7 +1753,6 @@ class Title {
                } else {
                        $this->mHasCascadingRestrictions = $sources;
                }
-
                return array( $sources, $pagerestrictions );
        }
 
@@ -1754,10 +1774,10 @@ class Title {
 
                foreach( $wgRestrictionTypes as $type ){
                        $this->mRestrictions[$type] = array();
+                       $this->mRestrictionsExpiry[$type] = Block::decodeExpiry('');
                }
 
                $this->mCascadeRestriction = false;
-               $this->mRestrictionsExpiry = Block::decodeExpiry('');
 
                # Backwards-compatibility: also load the restrictions from the page record (old format).
 
@@ -1801,7 +1821,7 @@ class Title {
 
                                // Only apply the restrictions if they haven't expired!
                                if ( !$expiry || $expiry > $now ) {
-                                       $this->mRestrictionsExpiry = $expiry;
+                                       $this->mRestrictionsExpiry[$row->pr_type] = $expiry;
                                        $this->mRestrictions[$row->pr_type] = explode( ',', trim( $row->pr_level ) );
 
                                        $this->mCascadeRestriction |= $row->pr_cascade;
@@ -1842,13 +1862,13 @@ class Title {
 
                                        if (!$expiry || $expiry > $now) {
                                                // Apply the restrictions
-                                               $this->mRestrictionsExpiry = $expiry;
+                                               $this->mRestrictionsExpiry['create'] = $expiry;
                                                $this->mRestrictions['create'] = explode(',', trim($pt_create_perm) );
                                        } else { // Get rid of the old restrictions
                                                Title::purgeExpiredRestrictions();
                                        }
                                } else {
-                                       $this->mRestrictionsExpiry = Block::decodeExpiry('');
+                                       $this->mRestrictionsExpiry['create'] = Block::decodeExpiry('');
                                }
                                $this->mRestrictionsLoaded = true;
                        }
@@ -1873,7 +1893,7 @@ class Title {
         * Accessor/initialisation for mRestrictions
         *
         * @param $action \type{\string} action that permission needs to be checked for
-        * @return \arrayof{\string} the array of groups allowed to edit this article
+        * @return \type{\arrayof{\string}} the array of groups allowed to edit this article
         */
        public function getRestrictions( $action ) {
                if( !$this->mRestrictionsLoaded ) {
@@ -1884,6 +1904,18 @@ class Title {
                                : array();
        }
 
+       /**
+        * Get the expiry time for the restriction against a given action
+        * @return 14-char timestamp, or 'infinity' if the page is protected forever 
+        * or not protected at all, or false if the action is not recognised.
+        */
+       public function getRestrictionExpiry( $action ) {
+               if( !$this->mRestrictionsLoaded ) {
+                       $this->loadRestrictions();
+               }
+               return isset( $this->mRestrictionsExpiry[$action] ) ? $this->mRestrictionsExpiry[$action] : false;
+       }
+
        /**
         * Is there a version of this page in the deletion archive?
         * @return \type{\int} the number of archived revisions
@@ -1913,12 +1945,13 @@ class Title {
         */
        public function getArticleID( $flags = 0 ) {
                $linkCache = LinkCache::singleton();
-               if ( $flags & GAID_FOR_UPDATE ) {
+               if( $flags & GAID_FOR_UPDATE ) {
                        $oldUpdate = $linkCache->forUpdate( true );
+                       $linkCache->clearLink( $this );
                        $this->mArticleID = $linkCache->addLinkObj( $this );
                        $linkCache->forUpdate( $oldUpdate );
                } else {
-                       if ( -1 == $this->mArticleID ) {
+                       if( -1 == $this->mArticleID ) {
                                $this->mArticleID = $linkCache->addLinkObj( $this );
                        }
                }
@@ -1971,14 +2004,14 @@ class Title {
         * @return \type{\int}
         */
        public function getLatestRevID( $flags = 0 ) {
-               if ($this->mLatestID !== false)
+               if( $this->mLatestID !== false )
                        return $this->mLatestID;
 
                $db = ($flags & GAID_FOR_UPDATE) ? wfGetDB(DB_MASTER) : wfGetDB(DB_SLAVE);
-               return $this->mLatestID = $db->selectField( 'revision',
-                       "max(rev_id)",
-                       array('rev_page' => $this->getArticleID($flags)),
-                       'Title::getLatestRevID' );
+               $this->mLatestID = $db->selectField( 'page', 'page_latest',
+                       array( 'page_namespace' => $this->getNamespace(), 'page_title' => $this->getDBKey() ),
+                       __METHOD__ );
+               return $this->mLatestID;
        }
 
        /**
@@ -2263,13 +2296,14 @@ class Title {
        }
 
        /**
-        * Set the fragment for this title
-        * This is kind of bad, since except for this rarely-used function, Title objects
-        * are immutable. The reason this is here is because it's better than setting the
-        * members directly, which is what Linker::formatComment was doing previously.
+        * Set the fragment for this title. Removes the first character from the
+        * specified fragment before setting, so it assumes you're passing it with 
+        * an initial "#".
+        *
+        * Deprecated for public use, use Title::makeTitle() with fragment parameter.
+        * Still in active use privately.
         *
         * @param $fragment \type{\string} text
-        * @todo clarify whether access is supposed to be public (was marked as "kind of public")
         */
        public function setFragment( $fragment ) {
                $this->mFragment = str_replace( '_', ' ', substr( $fragment, 1 ) );
@@ -2301,7 +2335,7 @@ class Title {
         * On heavily-used templates it will max out the memory.
         *
         * @param $options \type{\string} may be FOR UPDATE
-        * @return \arrayof{Title} the Title objects linking here
+        * @return \type{\arrayof{Title}} the Title objects linking here
         */
        public function getLinksTo( $options = '', $table = 'pagelinks', $prefix = 'pl' ) {
                $linkCache = LinkCache::singleton();
@@ -2342,7 +2376,7 @@ class Title {
         * On heavily-used templates it will max out the memory.
         *
         * @param $options \type{\string} may be FOR UPDATE
-        * @return \arrayof{Title} the Title objects linking here
+        * @return \type{\arrayof{Title}} the Title objects linking here
         */
        public function getTemplateLinksTo( $options = '' ) {
                return $this->getLinksTo( $options, 'templatelinks', 'tl' );
@@ -2353,7 +2387,7 @@ class Title {
         *
         * @todo check if needed (used only in SpecialBrokenRedirects.php, and should use redirect table in this case)
         * @param $options \type{\string} may be FOR UPDATE
-        * @return \arrayof{Title} the Title objects
+        * @return \type{\arrayof{Title}} the Title objects
         */
        public function getBrokenLinksFrom( $options = '' ) {
                if ( $this->getArticleId() == 0 ) {
@@ -2396,7 +2430,7 @@ class Title {
         * Get a list of URLs to purge from the Squid cache when this
         * page changes
         *
-        * @return \arrayof{\string} the URLs
+        * @return \type{\arrayof{\string}} the URLs
         */
        public function getSquidURLs() {
                global $wgContLang;
@@ -2491,13 +2525,19 @@ class Title {
 
                if ( $auth ) {
                        global $wgUser;
-                       $errors = array_merge($errors, 
+                       $errors = wfArrayMerge($errors, 
                                        $this->getUserPermissionsErrors('move', $wgUser),
                                        $this->getUserPermissionsErrors('edit', $wgUser),
                                        $nt->getUserPermissionsErrors('move', $wgUser),
                                        $nt->getUserPermissionsErrors('edit', $wgUser));
                }
 
+               $match = EditPage::matchSpamRegex( $reason );
+               if( $match !== false ) {
+                       // This is kind of lame, won't display nice
+                       $errors[] = array('spamprotectiontext');
+               }
+               
                global $wgUser;
                $err = null;
                if( !wfRunHooks( 'AbortMove', array( $this, $nt, $wgUser, &$err, $reason ) ) ) {
@@ -2541,6 +2581,7 @@ class Title {
                }
 
                $pageid = $this->getArticleID();
+               $protected = $this->isProtected();
                if( $nt->exists() ) {
                        $err = $this->moveOverExistingRedirect( $nt, $reason, $createRedirect );
                        $pageCountChange = ($createRedirect ? 0 : -1);
@@ -2575,8 +2616,28 @@ class Title {
                                'cl_sortkey' => $this->getPrefixedText() ),
                        __METHOD__ );
 
-               # Update watchlists
+               if( $protected ) {
+                       # Protect the redirect title as the title used to be...
+                       $dbw->insertSelect( 'page_restrictions', 'page_restrictions',
+                               array( 
+                                       'pr_page'    => $redirid,
+                                       'pr_type'    => 'pr_type',
+                                       'pr_level'   => 'pr_level',
+                                       'pr_cascade' => 'pr_cascade',
+                                       'pr_user'    => 'pr_user',
+                                       'pr_expiry'  => 'pr_expiry'
+                               ),
+                               array( 'pr_page' => $pageid ),
+                               __METHOD__,
+                               array( 'IGNORE' )
+                       );
+                       # Update the protection log
+                       $log = new LogPage( 'protect' );
+                       $comment = wfMsgForContent('1movedto2',$this->getPrefixedText(), $nt->getPrefixedText() );
+                       $log->addEntry( 'protect', $nt, $comment, array() ); // FIXME: $params?
+               }
 
+               # Update watchlists
                $oldnamespace = $this->getNamespace() & ~1;
                $newnamespace = $nt->getNamespace() & ~1;
                $oldtitle = $this->getDBkey();
@@ -2649,7 +2710,6 @@ class Title {
                $latest = $this->getLatestRevID();
 
                $dbw = wfGetDB( DB_MASTER );
-               $dbw->begin();
 
                # Delete the old redirect. We don't save it to history since
                # by definition if we've got here it's rather uninteresting.
@@ -2729,7 +2789,6 @@ class Title {
                                }
                        }
                }
-               $dbw->commit();
 
                # Log the move
                $log = new LogPage( 'move' );
@@ -2756,7 +2815,9 @@ class Title {
                $fname = 'MovePageForm::moveToNewTitle';
                $comment = wfMsgForContent( '1movedto2', $this->getPrefixedText(), $nt->getPrefixedText() );
                if ( $reason ) {
-                       $comment .= ": $reason";
+                       $comment .= wfMsgExt( 'colon-separator',
+                               array( 'escapenoentities', 'content' ) );
+                       $comment .= $reason;
                }
 
                $newid = $nt->getArticleID();
@@ -2764,7 +2825,6 @@ class Title {
                $latest = $this->getLatestRevId();
                
                $dbw = wfGetDB( DB_MASTER );
-               $dbw->begin();
                $now = $dbw->timestamp();
 
                # Save a null revision in the page's history notifying of the move
@@ -2824,7 +2884,6 @@ class Title {
                                }
                        }
                }
-               $dbw->commit();
 
                # Log the move
                $log = new LogPage( 'move' );
@@ -3117,11 +3176,12 @@ class Title {
 
        /**
         * Get the last touched timestamp
+        * @param Database $db, optional db
         * @return \type{\string} Last touched timestamp
         */
-       public function getTouched() {
-               $dbr = wfGetDB( DB_SLAVE );
-               $touched = $dbr->selectField( 'page', 'page_touched',
+       public function getTouched( $db = NULL ) {
+               $db = isset($db) ? $db : wfGetDB( DB_SLAVE );
+               $touched = $db->selectField( 'page', 'page_touched',
                        array(
                                'page_namespace' => $this->getNamespace(),
                                'page_title' => $this->getDBkey()
@@ -3135,10 +3195,10 @@ class Title {
         * @return \type{\string} Trackback URL
         */
        public function trackbackURL() {
-               global $wgTitle, $wgScriptPath, $wgServer;
+               global $wgScriptPath, $wgServer;
 
                return "$wgServer$wgScriptPath/trackback.php?article="
-                       . htmlspecialchars(urlencode($wgTitle->getPrefixedDBkey()));
+                       . htmlspecialchars(urlencode($this->getPrefixedDBkey()));
        }
 
        /**
@@ -3255,7 +3315,7 @@ class Title {
         *
         * @param $ns \twotypes{\int,\null} Single namespace to consider; 
         *            NULL to consider all namespaces
-        * @return \arrayof{Title} Redirects to this title
+        * @return \type{\arrayof{Title}} Redirects to this title
         */
        public function getRedirectsHere( $ns = null ) {
                $redirs = array();