Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.19% covered (warning)
67.19%
170 / 253
29.41% covered (danger)
29.41%
5 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
TitleLibrary
67.19% covered (warning)
67.19%
170 / 253
29.41% covered (danger)
29.41%
5 / 17
286.34
0.00% covered (danger)
0.00%
0 / 1
 register
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 checkNamespace
75.00% covered (warning)
75.00%
12 / 16
0.00% covered (danger)
0.00%
0 / 1
7.77
 getInexpensiveTitleData
78.95% covered (warning)
78.95%
15 / 19
0.00% covered (danger)
0.00%
0 / 1
6.34
 getExpensiveData
87.50% covered (warning)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
7.10
 newTitle
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
9.08
 makeTitle
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 getUrl
86.96% covered (warning)
86.96%
20 / 23
0.00% covered (danger)
0.00%
0 / 1
7.11
 getContentInternal
47.83% covered (danger)
47.83%
11 / 23
0.00% covered (danger)
0.00%
0 / 1
13.96
 getContent
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getCategories
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 getFileInfo
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
72
 makeArrayOneBased
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 protectionLevels
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
3.05
 cascadingProtection
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
3.01
 redirectTarget
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 recordVaryFlag
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getPageLangCode
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
1<?php
2
3namespace MediaWiki\Extension\Scribunto\Engines\LuaCommon;
4
5use LogicException;
6use MediaWiki\Content\Content;
7use MediaWiki\Logger\LoggerFactory;
8use MediaWiki\MediaWikiServices;
9use MediaWiki\Parser\ParserOutputFlags;
10use MediaWiki\Revision\RevisionAccessException;
11use MediaWiki\Revision\SlotRecord;
12use MediaWiki\Title\Title;
13
14class TitleLibrary extends LibraryBase {
15    // Note these caches are naturally limited to
16    // $wgExpensiveParserFunctionLimit + 1 actual Title objects because any
17    // addition besides the one for the current page calls
18    // incrementExpensiveFunctionCount()
19    /** @var Title[] */
20    private $titleCache = [];
21    /** @var (Title|null)[] */
22    private $idCache = [ 0 => null ];
23
24    public function register() {
25        $lib = [
26            'newTitle' => [ $this, 'newTitle' ],
27            'makeTitle' => [ $this, 'makeTitle' ],
28            'getExpensiveData' => [ $this, 'getExpensiveData' ],
29            'getUrl' => [ $this, 'getUrl' ],
30            'getContent' => [ $this, 'getContent' ],
31            'getCategories' => [ $this, 'getCategories' ],
32            'getFileInfo' => [ $this, 'getFileInfo' ],
33            'protectionLevels' => [ $this, 'protectionLevels' ],
34            'cascadingProtection' => [ $this, 'cascadingProtection' ],
35            'redirectTarget' => [ $this, 'redirectTarget' ],
36            'recordVaryFlag' => [ $this, 'recordVaryFlag' ],
37            'getPageLangCode' => [ $this, 'getPageLangCode' ],
38        ];
39        $title = $this->getTitle();
40        return $this->getEngine()->registerInterface( 'mw.title.lua', $lib, [
41            'thisTitle' => $title ? $this->getInexpensiveTitleData( $title ) : null,
42            'NS_MEDIA' => NS_MEDIA,
43        ] );
44    }
45
46    /**
47     * Check a namespace parameter
48     * @param string $name Function name (for errors)
49     * @param int $argIdx Argument index (for errors)
50     * @param mixed &$arg Argument
51     * @param int|null $default Default value, if $arg is null
52     */
53    private function checkNamespace( $name, $argIdx, &$arg, $default = null ) {
54        if ( $arg === null && $default !== null ) {
55            $arg = $default;
56        } elseif ( is_numeric( $arg ) ) {
57            $arg = (int)$arg;
58            if ( !MediaWikiServices::getInstance()->getNamespaceInfo()->exists( $arg ) ) {
59                throw new LuaError(
60                    "bad argument #$argIdx to '$name' (unrecognized namespace number '$arg')"
61                );
62            }
63        } elseif ( is_string( $arg ) ) {
64            $ns = MediaWikiServices::getInstance()->getContentLanguage()->getNsIndex( $arg );
65            if ( $ns === false ) {
66                throw new LuaError(
67                    "bad argument #$argIdx to '$name' (unrecognized namespace name '$arg')"
68                );
69            }
70            $arg = $ns;
71        } else {
72            $this->checkType( $name, $argIdx, $arg, 'namespace number or name' );
73        }
74    }
75
76    /**
77     * Extract inexpensive information from a Title object for return to Lua
78     *
79     * @param Title $title Title to return
80     * @return array Lua data
81     */
82    private function getInexpensiveTitleData( Title $title ) {
83        $ns = $title->getNamespace();
84        $ret = [
85            'isCurrentTitle' => (bool)$title->equals( $this->getTitle() ),
86            'isLocal' => (bool)$title->isLocal(),
87            'interwiki' => $title->getInterwiki(),
88            'namespace' => $ns,
89            'nsText' => $title->getNsText(),
90            'text' => $title->getText(),
91            'fragment' => $title->getFragment(),
92            'thePartialUrl' => $title->getPartialURL(),
93        ];
94        if ( $ns === NS_SPECIAL ) {
95            // Core doesn't currently record special page links, but it may in the future.
96            if ( $this->getParser() && !$title->equals( $this->getTitle() ) ) {
97                $this->getParser()->getOutput()->addLink( $title );
98            }
99            $ret['exists'] = MediaWikiServices::getInstance()
100                ->getSpecialPageFactory()->exists( $title->getDBkey() );
101        }
102        if ( $ns !== NS_FILE && $ns !== NS_MEDIA ) {
103            $ret['file'] = false;
104        }
105        return $ret;
106    }
107
108    /**
109     * Extract expensive information from a Title object for return to Lua
110     *
111     * This records a link to this title in the current ParserOutput and caches the
112     * title for repeated lookups. It may call incrementExpensiveFunctionCount() if
113     * the title is not already cached.
114     *
115     * @internal
116     * @param string $text Title text
117     * @return array Lua data
118     */
119    public function getExpensiveData( $text ) {
120        $this->checkType( 'getExpensiveData', 1, $text, 'string' );
121        $title = Title::newFromText( $text );
122        if ( !$title ) {
123            return [ null ];
124        }
125        $dbKey = $title->getPrefixedDBkey();
126        if ( isset( $this->titleCache[$dbKey] ) ) {
127            // It was already cached, so we already did the expensive work and added a link
128            $title = $this->titleCache[$dbKey];
129        } else {
130            if ( !$title->equals( $this->getTitle() ) ) {
131                $this->incrementExpensiveFunctionCount();
132
133                // Record a link
134                if ( $this->getParser() ) {
135                    $this->getParser()->getOutput()->addLink( $title );
136                }
137            }
138
139            // Cache it
140            $this->titleCache[$dbKey] = $title;
141            if ( $title->getArticleID() > 0 ) {
142                $this->idCache[$title->getArticleID()] = $title;
143            }
144        }
145
146        $ret = [
147            'isRedirect' => (bool)$title->isRedirect(),
148            'id' => $title->getArticleID(),
149            'contentModel' => $title->getContentModel(),
150        ];
151        if ( $title->getNamespace() === NS_SPECIAL ) {
152            $ret['exists'] = MediaWikiServices::getInstance()
153                ->getSpecialPageFactory()->exists( $title->getDBkey() );
154        } else {
155            // bug 70495: don't just check whether the ID != 0
156            $ret['exists'] = $title->exists();
157        }
158        return [ $ret ];
159    }
160
161    /**
162     * Handler for title.new
163     *
164     * Calls Title::newFromID or Title::newFromTitle as appropriate for the
165     * arguments.
166     *
167     * @internal
168     * @param string|int $text_or_id Title or page_id to fetch
169     * @param string|int|null $defaultNamespace Namespace name or number to use if
170     *  $text_or_id doesn't override
171     * @return array Lua data
172     */
173    public function newTitle( $text_or_id, $defaultNamespace = null ) {
174        $type = $this->getLuaType( $text_or_id );
175        if ( $type === 'number' ) {
176            if ( array_key_exists( $text_or_id, $this->idCache ) ) {
177                $title = $this->idCache[$text_or_id];
178            } else {
179                $this->incrementExpensiveFunctionCount();
180                $title = Title::newFromID( $text_or_id );
181                $this->idCache[$text_or_id] = $title;
182
183                // Record a link
184                if ( $title && $this->getParser() && !$title->equals( $this->getTitle() ) ) {
185                    $this->getParser()->getOutput()->addLink( $title );
186                }
187            }
188            if ( $title ) {
189                $this->titleCache[$title->getPrefixedDBkey()] = $title;
190            } else {
191                return [ null ];
192            }
193        } elseif ( $type === 'string' ) {
194            $this->checkNamespace( 'title.new', 2, $defaultNamespace, NS_MAIN );
195
196            // Note this just fills in the given fields, it doesn't fetch from
197            // the page table.
198            $title = Title::newFromText( $text_or_id, $defaultNamespace );
199            if ( !$title ) {
200                return [ null ];
201            }
202        } else {
203            $this->checkType( 'title.new', 1, $text_or_id, 'number or string' );
204            throw new LogicException( 'checkType above should have failed' );
205        }
206
207        return [ $this->getInexpensiveTitleData( $title ) ];
208    }
209
210    /**
211     * Handler for title.makeTitle
212     *
213     * Calls Title::makeTitleSafe.
214     *
215     * @internal
216     * @param string|int $ns Namespace
217     * @param string $text Title text
218     * @param string|null $fragment URI fragment
219     * @param string|null $interwiki Interwiki code
220     * @return array Lua data
221     */
222    public function makeTitle( $ns, $text, $fragment = null, $interwiki = null ) {
223        $this->checkNamespace( 'makeTitle', 1, $ns );
224        $this->checkType( 'makeTitle', 2, $text, 'string' );
225        $this->checkTypeOptional( 'makeTitle', 3, $fragment, 'string', '' );
226        $this->checkTypeOptional( 'makeTitle', 4, $interwiki, 'string', '' );
227
228        // Note this just fills in the given fields, it doesn't fetch from the
229        // page table.
230        $title = Title::makeTitleSafe( $ns, $text, $fragment, $interwiki );
231        if ( !$title ) {
232            return [ null ];
233        }
234
235        return [ $this->getInexpensiveTitleData( $title ) ];
236    }
237
238    /**
239     * Get a URL referring to this title
240     * @internal
241     * @param string $text Title text.
242     * @param string $which 'fullUrl', 'localUrl', or 'canonicalUrl'
243     * @param string|array|null $query Query string or query string data.
244     * @param string|null $proto 'http', 'https', 'relative', or 'canonical'
245     * @return array
246     */
247    public function getUrl( $text, $which, $query = null, $proto = null ) {
248        static $protoMap = [
249            'http' => PROTO_HTTP,
250            'https' => PROTO_HTTPS,
251            'relative' => PROTO_RELATIVE,
252            'canonical' => PROTO_CANONICAL,
253        ];
254
255        $this->checkType( 'getUrl', 1, $text, 'string' );
256        $this->checkType( 'getUrl', 2, $which, 'string' );
257        if ( !in_array( $which, [ 'fullUrl', 'localUrl', 'canonicalUrl' ], true ) ) {
258            $this->checkType( 'getUrl', 2, $which, "'fullUrl', 'localUrl', or 'canonicalUrl'" );
259        }
260
261        // May call the following Title methods:
262        // getFullUrl, getLocalUrl, getCanonicalUrl
263        $func = "get" . ucfirst( $which );
264
265        $args = [ $query, false ];
266        if ( !is_string( $query ) && !is_array( $query ) ) {
267            $this->checkTypeOptional( $which, 1, $query, 'table or string', '' );
268        }
269        if ( $which === 'fullUrl' ) {
270            $this->checkTypeOptional( $which, 2, $proto, 'string', 'relative' );
271            if ( !isset( $protoMap[$proto] ) ) {
272                $this->checkType( $which, 2, $proto, "'http', 'https', 'relative', or 'canonical'" );
273            }
274            $args[] = $protoMap[$proto];
275        }
276
277        $title = Title::newFromText( $text );
278        if ( !$title ) {
279            return [ null ];
280        }
281        return [ $title->$func( ...$args ) ];
282    }
283
284    /**
285     * Utility to get a Content object from a title
286     *
287     * The title is counted as a transclusion.
288     *
289     * @param string $text Title text
290     * @return Content|null The Content object of the title, null if missing
291     */
292    private function getContentInternal( $text ) {
293        $title = Title::newFromText( $text );
294        if ( !$title || !$title->canExist() ) {
295            return null;
296        }
297
298        $rev = $this->getParser()->fetchCurrentRevisionRecordOfTitle( $title );
299
300        if ( $title->equals( $this->getTitle() ) ) {
301            $parserOutput = $this->getParser()->getOutput();
302            $parserOutput->setOutputFlag( ParserOutputFlags::VARY_REVISION_SHA1 );
303            $parserOutput->setRevisionUsedSha1Base36( $rev ? $rev->getSha1() : '' );
304            wfDebug( __METHOD__ . ": set vary-revision-sha1 for '$title'" );
305        } else {
306            // Record in templatelinks, so edits cause the page to be refreshed
307            $this->getParser()->getOutput()->addTemplate(
308                $title, $title->getArticleID(), $title->getLatestRevID()
309            );
310        }
311
312        if ( !$rev ) {
313            return null;
314        }
315
316        try {
317            $content = $rev->getContent( SlotRecord::MAIN );
318        } catch ( RevisionAccessException $ex ) {
319            $logger = LoggerFactory::getInstance( 'Scribunto' );
320            $logger->warning(
321                __METHOD__ . ': Unable to transclude revision content',
322                [ 'exception' => $ex ]
323            );
324            $content = null;
325        }
326        return $content;
327    }
328
329    /**
330     * Handler for getContent
331     * @internal
332     * @param string $text
333     * @return string[]|null[]
334     */
335    public function getContent( $text ) {
336        $this->checkType( 'getContent', 1, $text, 'string' );
337        $content = $this->getContentInternal( $text );
338        return [ $content ? $content->serialize() : null ];
339    }
340
341    /**
342     * @internal
343     * @param string $text
344     * @return string[][]
345     */
346    public function getCategories( $text ) {
347        $this->checkType( 'getCategories', 1, $text, 'string' );
348        $title = Title::newFromText( $text );
349        if ( !$title ) {
350            return [ [] ];
351        }
352        $page = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title );
353        $this->incrementExpensiveFunctionCount();
354
355        $parserOutput = $this->getParser()->getOutput();
356        if ( $title->equals( $this->getTitle() ) ) {
357            $parserOutput->setOutputFlag( ParserOutputFlags::VARY_REVISION );
358        } else {
359            // Record in templatelinks, so edits cause the page to be refreshed
360            $parserOutput->addTemplate( $title, $title->getArticleID(), $title->getLatestRevID() );
361        }
362
363        $categoryTitles = $page->getCategories();
364        $categoryNames = [];
365        foreach ( $categoryTitles as $title ) {
366            $categoryNames[] = $title->getText();
367        }
368        return [ self::makeArrayOneBased( $categoryNames ) ];
369    }
370
371    /**
372     * Handler for getFileInfo
373     * @internal
374     * @param string $text
375     * @return array
376     */
377    public function getFileInfo( $text ) {
378        $this->checkType( 'getFileInfo', 1, $text, 'string' );
379        $title = Title::newFromText( $text );
380        if ( !$title ) {
381            return [ false ];
382        }
383        $ns = $title->getNamespace();
384        if ( $ns !== NS_FILE && $ns !== NS_MEDIA ) {
385            return [ false ];
386        }
387
388        $this->incrementExpensiveFunctionCount();
389        $file = MediaWikiServices::getInstance()->getRepoGroup()->findFile( $title );
390        if ( !$file ) {
391            return [ [ 'exists' => false ] ];
392        }
393        $this->getParser()->getOutput()->addImage(
394            $file->getName(), $file->getTimestamp(), $file->getSha1()
395        );
396        if ( !$file->exists() ) {
397            return [ [ 'exists' => false ] ];
398        }
399        $pageCount = $file->pageCount();
400        if ( $pageCount === false ) {
401            $pages = null;
402        } else {
403            $pages = [];
404            for ( $i = 1; $i <= $pageCount; ++$i ) {
405                $pages[$i] = [
406                    'width' => $file->getWidth( $i ),
407                    'height' => $file->getHeight( $i )
408                ];
409            }
410        }
411        return [ [
412            'exists' => true,
413            'width' => $file->getWidth(),
414            'height' => $file->getHeight(),
415            'mimeType' => $file->getMimeType(),
416            'length' => $file->getLength(),
417            'size' => $file->getSize(),
418            'pages' => $pages
419        ] ];
420    }
421
422    /**
423     * Renumber an array for return to Lua
424     * @param array $arr
425     * @return array
426     */
427    private static function makeArrayOneBased( $arr ) {
428        if ( !$arr ) {
429            return $arr;
430        }
431        return array_combine( range( 1, count( $arr ) ), array_values( $arr ) );
432    }
433
434    /**
435     * Handler for protectionLevels
436     * @internal
437     * @param string $text
438     * @return array
439     */
440    public function protectionLevels( $text ) {
441        $this->checkType( 'protectionLevels', 1, $text, 'string' );
442        $title = Title::newFromText( $text );
443        if ( !$title ) {
444            return [ null ];
445        }
446
447        $restrictionStore = MediaWikiServices::getInstance()->getRestrictionStore();
448
449        if ( !$restrictionStore->areRestrictionsLoaded( $title ) ) {
450            $this->incrementExpensiveFunctionCount();
451        }
452        return [ array_map(
453            [ self::class, 'makeArrayOneBased' ],
454            $restrictionStore->getAllRestrictions( $title )
455        ) ];
456    }
457
458    /**
459     * Handler for cascadingProtection
460     * @internal
461     * @param string $text
462     * @return array
463     */
464    public function cascadingProtection( $text ) {
465        $this->checkType( 'cascadingProtection', 1, $text, 'string' );
466        $title = Title::newFromText( $text );
467        if ( !$title ) {
468            return [ null ];
469        }
470
471        $restrictionStore = MediaWikiServices::getInstance()->getRestrictionStore();
472        $titleFormatter = MediaWikiServices::getInstance()->getTitleFormatter();
473
474        if ( !$restrictionStore->areCascadeProtectionSourcesLoaded( $title ) ) {
475            $this->incrementExpensiveFunctionCount();
476        }
477
478        [ $sources, $restrictions ] = $restrictionStore->getCascadeProtectionSources( $title );
479
480        return [ [
481            'sources' => self::makeArrayOneBased( array_map(
482                static function ( $t ) use ( $titleFormatter ) {
483                    return $titleFormatter->getPrefixedText( $t );
484                },
485                $sources ) ),
486            'restrictions' => array_map(
487                [ self::class, 'makeArrayOneBased' ],
488                $restrictions
489            )
490        ] ];
491    }
492
493    /**
494     * Handler for redirectTarget
495     * @internal
496     * @param string $text
497     * @return string[]|null[]
498     */
499    public function redirectTarget( $text ) {
500        $this->checkType( 'redirectTarget', 1, $text, 'string' );
501        $content = $this->getContentInternal( $text );
502        $redirTitle = $content ? $content->getRedirectTarget() : null;
503        return [ $redirTitle ? $this->getInexpensiveTitleData( $redirTitle ) : null ];
504    }
505
506    /**
507     * Record a ParserOutput flag when the current title is accessed
508     * @internal
509     * @param string $text
510     * @param string $flag
511     * @return array
512     */
513    public function recordVaryFlag( $text, $flag ) {
514        $this->checkType( 'recordVaryFlag', 1, $text, 'string' );
515        $this->checkType( 'recordVaryFlag', 2, $flag, 'string' );
516        $title = Title::newFromText( $text );
517        if ( $title && $title->equals( $this->getTitle() ) ) {
518            // XXX note that we don't check this against the values defined
519            // in ParserOutputFlags
520            $this->getParser()->getOutput()->setOutputFlag( $flag );
521        }
522        return [];
523    }
524
525    /**
526     * Handler for getPageLangCode
527     * @internal
528     * @param string $text Title text.
529     * @return array<?string>
530     */
531    public function getPageLangCode( $text ) {
532        $title = Title::newFromText( $text );
533        if ( $title ) {
534            // If the page language is coming from the page record, we've
535            // probably accounted for the cost of reading the title from
536            // the DB already. However, a PageContentLanguage hook handler
537            // might get invoked here, and who knows how much that costs.
538            // Be safe and increment here, even though this could over-count.
539            $this->incrementExpensiveFunctionCount();
540            return [ $title->getPageLanguage()->getCode() ];
541        }
542        return [ null ];
543    }
544}