Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 161
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
GlobalUserPage
0.00% covered (danger)
0.00%
0 / 161
0.00% covered (danger)
0.00%
0 / 13
1980
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 showMissingArticle
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
56
 getRobotPolicy
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 loadModules
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 shouldDisplayGlobalPage
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 getCentralTouched
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
30
 isSourcePage
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getUsername
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getRemoteParsedText
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 canBeGlobal
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 newPage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 parseWikiText
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 getEnabledWikis
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * This program is free software: you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation, either version 3 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License
14 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
15 */
16
17namespace MediaWiki\GlobalUserPage;
18
19use Article;
20use MapCacheLRU;
21use MediaWiki\Config\Config;
22use MediaWiki\GlobalUserPage\Hooks\HookRunner;
23use MediaWiki\Html\Html;
24use MediaWiki\MediaWikiServices;
25use MediaWiki\Output\OutputPage;
26use MediaWiki\Parser\ParserOutput;
27use MediaWiki\Parser\ParserOutputFlags;
28use MediaWiki\Title\Title;
29use MediaWiki\User\User;
30use MediaWiki\WikiMap\WikiMap;
31use WANObjectCache;
32use Wikimedia\IPUtils;
33use Wikimedia\Parsoid\Core\TOCData;
34
35/**
36 * @property WikiGlobalUserPage $mPage Set by overwritten newPage() in this class
37 */
38class GlobalUserPage extends Article {
39
40    /**
41     * Cache version of action=parse
42     * output
43     */
44    private const PARSED_CACHE_VERSION = 4;
45
46    /**
47     * @var Config
48     */
49    private $config;
50
51    /**
52     * @var WANObjectCache
53     */
54    private $cache;
55
56    /**
57     * @var MapCacheLRU
58     */
59    private static $displayCache;
60
61    /**
62     * @var MapCacheLRU
63     */
64    private static $touchedCache;
65
66    public function __construct( Title $title, Config $config ) {
67        $this->config = $config;
68        $this->cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
69        parent::__construct( $title );
70    }
71
72    public function showMissingArticle() {
73        $title = $this->getTitle();
74
75        if ( !self::shouldDisplayGlobalPage( $title ) ) {
76            parent::showMissingArticle();
77
78            return;
79        }
80
81        $user = User::newFromName( $this->getUsername() );
82
83        $out = $this->getContext()->getOutput();
84        $parsedOutput = $this->getRemoteParsedText( self::getCentralTouched( $user ) );
85
86        // If the user page is empty or the API request failed, show the normal
87        // missing article page
88        if ( !$parsedOutput || !trim( $parsedOutput['text'] ) ) {
89            parent::showMissingArticle();
90
91            return;
92        }
93        $out->addHTML( $parsedOutput['text'] );
94        $out->addModuleStyles( 'ext.GlobalUserPage' );
95
96        // Set canonical URL to point to the source
97        $sourceURL = $this->mPage->getSourceURL();
98        $out->setCanonicalUrl( $sourceURL );
99        $footerKey = $this->config->get( 'GlobalUserPageFooterKey' );
100        if ( $footerKey ) {
101            $userLang = $this->getContext()->getLanguage();
102            $out->addHTML( Html::rawElement(
103                'div',
104                [
105                    'lang' => $userLang->getHtmlCode(),
106                    'dir' => $userLang->getDir(),
107                    'class' => 'mw-globaluserpage-footer plainlinks'
108                ],
109                "\n" . $out->msg( $footerKey )->params( $this->getUsername(), $sourceURL )->parse() . "\n"
110            ) );
111        }
112
113        // Load ParserOutput modules...
114        $this->loadModules( $out, $parsedOutput );
115
116        // Add indicators (T149286)
117        $out->setIndicators( $parsedOutput['indicators'] );
118
119        $pout = new ParserOutput;
120
121        // Add sections for new style of table of contents (T327942)
122        $sections = $parsedOutput['sections'] ?? null;
123        if ( $sections ) {
124            // FIXME: The action=parse API only outputs sections in the legacy format
125            $pout->setTOCData( TOCData::fromLegacy( $sections ) );
126            $pout->setOutputFlag( ParserOutputFlags::SHOW_TOC, $parsedOutput['showtoc'] ?? true );
127        }
128
129        // Add external links (T334805)
130        foreach ( $parsedOutput['externallinks'] ?? [] as $extLink ) {
131            $pout->addExternalLink( $extLink );
132        }
133
134        $out->addParserOutputMetadata( $pout );
135
136        // Make sure we set the correct robot policy
137        $policy = $this->getRobotPolicy( 'view' );
138        $out->setIndexPolicy( $policy['index'] );
139        $out->setFollowPolicy( $policy['follow'] );
140    }
141
142    /**
143     * Override robot policy to always set noindex (T177159)
144     *
145     * @param string $action
146     * @param ParserOutput|null $pOutput
147     * @return array
148     */
149    public function getRobotPolicy( $action, ParserOutput $pOutput = null ) {
150        $policy = parent::getRobotPolicy( $action, $pOutput );
151        if ( self::shouldDisplayGlobalPage( $this->getTitle() ) ) {
152            // Set noindex if this page is global
153            $policy['index'] = 'noindex';
154        }
155
156        return $policy;
157    }
158
159    /**
160     * Attempts to load modules through the
161     * ParserOutput on the local wiki, if
162     * they exist.
163     *
164     * @param OutputPage $out
165     * @param array $parsedOutput
166     */
167    private function loadModules( OutputPage $out, array $parsedOutput ) {
168        $rl = $out->getResourceLoader();
169        foreach ( $parsedOutput['modules'] as $module ) {
170            if ( $rl->isModuleRegistered( $module ) ) {
171                $out->addModules( $module );
172            }
173        }
174        foreach ( $parsedOutput['modulestyles'] as $module ) {
175            if ( $rl->isModuleRegistered( $module ) ) {
176                $out->addModuleStyles( $module );
177            }
178        }
179
180        $out->addJsConfigVars( $parsedOutput['jsconfigvars'] );
181    }
182
183    /**
184     * Given a Title, assuming it doesn't exist, should
185     * we display a global user page on it
186     *
187     * @param Title $title
188     * @return bool
189     */
190    public static function shouldDisplayGlobalPage( Title $title ) {
191        global $wgGlobalUserPageDBname;
192        if ( !self::canBeGlobal( $title ) ) {
193            return false;
194        }
195        // Do some instance caching since this can be
196        // called frequently due do the Linker hook
197        if ( !self::$displayCache ) {
198            self::$displayCache = new MapCacheLRU( 100 );
199        }
200
201        $text = $title->getPrefixedText();
202        if ( self::$displayCache->has( $text ) ) {
203            return self::$displayCache->get( $text );
204        }
205
206        // Normalize the username
207        $user = User::newFromName( $title->getText() );
208
209        if ( !$user ) {
210            self::$displayCache->set( $text, false );
211
212            return false;
213        }
214
215        // Make sure that the username represents the same
216        // user on both wikis.
217        $lookup = MediaWikiServices::getInstance()->getCentralIdLookupFactory()->getLookup();
218        if ( !$lookup->isAttached( $user ) || !$lookup->isAttached( $user, $wgGlobalUserPageDBname ) ) {
219            self::$displayCache->set( $text, false );
220
221            return false;
222        }
223
224        $touched = (bool)self::getCentralTouched( $user );
225        self::$displayCache->set( $text, $touched );
226
227        return $touched;
228    }
229
230    /**
231     * Get the page_touched of the central user page
232     *
233     * @todo this probably shouldn't be static
234     * @param User $user
235     * @return string|bool
236     */
237    protected static function getCentralTouched( User $user ) {
238        if ( !self::$touchedCache ) {
239            self::$touchedCache = new MapCacheLRU( 100 );
240        }
241        if ( self::$touchedCache->has( $user->getName() ) ) {
242            return self::$touchedCache->get( $user->getName() );
243        }
244
245        global $wgGlobalUserPageDBname;
246        $factory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
247        $mainLB = $factory->getMainLB( $wgGlobalUserPageDBname );
248
249        $dbr = $mainLB->getConnection( DB_REPLICA, [], $wgGlobalUserPageDBname );
250        $row = $dbr->newSelectQueryBuilder()
251            ->select( [ 'page_touched', 'pp_propname' ] )
252            ->from( 'page' )
253            ->leftJoin( 'page_props', null, [ 'page_id=pp_page', 'pp_propname' => 'noglobal' ] )
254            ->where( [
255                'page_namespace' => NS_USER,
256                'page_title' => $user->getUserPage()->getDBkey(),
257            ] )
258            ->caller( __METHOD__ )
259            ->fetchRow();
260        if ( $row ) {
261            if ( $row->pp_propname == 'noglobal' ) {
262                $touched = false;
263            } else {
264                $touched = $row->page_touched;
265            }
266        } else {
267            $touched = false;
268        }
269
270        self::$touchedCache->set( $user->getName(), $touched );
271
272        return $touched;
273    }
274
275    /**
276     * Given a Title, is it a source page we might
277     * be "transcluding" on another site
278     *
279     * @return bool
280     */
281    public function isSourcePage() {
282        if ( WikiMap::getCurrentWikiId() !== $this->config->get( 'GlobalUserPageDBname' ) ) {
283            return false;
284        }
285
286        $title = $this->getTitle();
287        if ( !$title->inNamespace( NS_USER ) ) {
288            return false;
289        }
290
291        // Root user page
292        return $title->getRootTitle()->equals( $title );
293    }
294
295    /**
296     * Username for the given global user page
297     *
298     * @return string
299     */
300    public function getUsername() {
301        return $this->mPage->getUsername();
302    }
303
304    /**
305     * We trust that the remote wiki has done proper HTML escaping and isn't
306     * crazy by having raw HTML enabled.
307     *
308     * @param string $touched The page_touched for the page
309     * @return array|bool
310     * @return-taint escaped
311     */
312    public function getRemoteParsedText( $touched ) {
313        $langCode = $this->getContext()->getLanguage()->getCode();
314        $skinName = $this->getContext()->getSkin()->getSkinName();
315
316        $cache = $this->cache;
317
318        return $cache->getWithSetCallback(
319            // Need language and skin in the key since we pass them to the API
320            $cache->makeGlobalKey(
321                'globaluserpage-parsed',
322                $touched,
323                $langCode,
324                $skinName,
325                md5( $this->getUsername() )
326            ),
327            $this->config->get( 'GlobalUserPageCacheExpiry' ),
328            function ( $oldValue, &$ttl ) use ( $langCode, $skinName, $cache ) {
329                $data = $this->parseWikiText( $this->getTitle(), $langCode, $skinName );
330                if ( !$data ) {
331                    // Cache failure for 10 seconds
332                    $ttl = $cache::TTL_UNCACHEABLE;
333                }
334
335                return $data;
336            },
337            [ 'version' => self::PARSED_CACHE_VERSION ]
338        );
339    }
340
341    /**
342     * Checks whether the given page can be global
343     * doesn't check the actual database
344     * @param Title $title
345     * @return bool
346     */
347    protected static function canBeGlobal( Title $title ) {
348        global $wgGlobalUserPageDBname;
349        // Don't run this code for Hub.
350        if ( WikiMap::getCurrentWikiId() === $wgGlobalUserPageDBname ) {
351            return false;
352        }
353
354        // Must be a user page
355        if ( !$title->inNamespace( NS_USER ) ) {
356            return false;
357        }
358
359        // Check it's a root user page
360        if ( $title->getRootText() !== $title->getText() ) {
361            return false;
362        }
363
364        // Check valid username
365        if ( !MediaWikiServices::getInstance()->getUserNameUtils()->isValid( $title->getText() ) ) {
366            return false;
367        }
368
369        // IPs don't get global userpages
370        return !IPUtils::isIPAddress( $title->getText() );
371    }
372
373    /**
374     * @param Title $title
375     * @return WikiGlobalUserPage
376     */
377    public function newPage( Title $title ) {
378        return new WikiGlobalUserPage( $title, $this->config );
379    }
380
381    /**
382     * Use action=parse to get rendered HTML of a page
383     *
384     * @param Title $title
385     * @param string $langCode
386     * @param string $skinName
387     * @return array|bool
388     */
389    protected function parseWikiText( Title $title, $langCode, $skinName ) {
390        $unLocalizedName = MediaWikiServices::getInstance()
391            ->getNamespaceInfo()
392            ->getCanonicalName( NS_USER ) . ':' . $title->getText();
393        $wikitext = '{{:' . $unLocalizedName . '}}';
394        $params = [
395            'action' => 'parse',
396            'title' => $unLocalizedName,
397            'text' => $wikitext,
398            'disableeditsection' => 1,
399            'disablelimitreport' => 1,
400            'uselang' => $langCode,
401            'useskin' => $skinName,
402            'prop' => 'text|modules|jsconfigvars|indicators|sections|externallinks',
403            'formatversion' => 2,
404        ];
405        $data = $this->mPage->makeAPIRequest( $params );
406
407        // (T328694) Don't read 'parse' key blindly, it might not be set
408        return $data !== false ? ( $data['parse'] ?? false ) : false;
409    }
410
411    /**
412     * @return array
413     */
414    public static function getEnabledWikis() {
415        static $list = null;
416        if ( $list === null ) {
417            $list = [];
418            $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
419            if ( $hookRunner->onGlobalUserPageWikis( $list ) ) {
420                // Fallback if no hook override
421                global $wgLocalDatabases;
422                $list = $wgLocalDatabases;
423            }
424        }
425
426        return $list;
427    }
428}