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