Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
8.61% covered (danger)
8.61%
75 / 871
6.82% covered (danger)
6.82%
3 / 44
CRAP
0.00% covered (danger)
0.00%
0 / 1
Linker
8.61% covered (danger)
8.61%
75 / 871
6.82% covered (danger)
6.82%
3 / 44
80450.24
0.00% covered (danger)
0.00%
0 / 1
 link
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 linkKnown
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 makeSelfLinkObj
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 getInvalidTitleDescription
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 fnamePart
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 makeExternalImage
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 makeImageLink
0.00% covered (danger)
0.00%
0 / 139
0.00% covered (danger)
0.00%
0 / 1
4160
 getImageLinkMTOParams
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
110
 makeThumbLinkObj
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 makeThumbLink2
0.00% covered (danger)
0.00%
0 / 142
0.00% covered (danger)
0.00%
0 / 1
3422
 processResponsiveImages
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
132
 makeBrokenImageLinkObj
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
156
 getUploadUrl
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 makeMediaLinkObj
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 makeMediaLinkFile
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 specialLink
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 makeExternalLink
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 userLink
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 userToolLinkArray
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
210
 renderUserToolLinksArray
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 userToolLinks
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
2.15
 userToolLinksRedContribs
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 userTalkLink
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
4.94
 blockLink
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
2.86
 emailLink
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
2.86
 revUserLink
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 getRevisionDeletedClass
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 revUserTools
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 expandLocalLinks
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 normalizeSubpageLink
0.00% covered (danger)
0.00%
0 / 38
0.00% covered (danger)
0.00%
0 / 1
210
 formatRevisionSize
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 splitTrail
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 generateRollback
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
5.03
 getRollbackEditCount
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
132
 buildRollbackLink
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
132
 formatHiddenCategories
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getContextFromMain
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 titleAttrib
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
132
 accesskey
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
42
 getRevDeleteLink
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
42
 revDeleteLink
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 revDeleteLinkDisabled
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 tooltipAndAccesskeyAttribs
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 tooltip
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Methods to make links and related items.
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23namespace MediaWiki\Linker;
24
25use HtmlArmor;
26use MediaTransformError;
27use MediaTransformOutput;
28use MediaWiki\Context\ContextSource;
29use MediaWiki\Context\DerivativeContext;
30use MediaWiki\Context\IContextSource;
31use MediaWiki\Context\RequestContext;
32use MediaWiki\FileRepo\File\File;
33use MediaWiki\HookContainer\HookRunner;
34use MediaWiki\Html\Html;
35use MediaWiki\Html\HtmlHelper;
36use MediaWiki\MainConfigNames;
37use MediaWiki\MediaWikiServices;
38use MediaWiki\Message\Message;
39use MediaWiki\Parser\Parser;
40use MediaWiki\Permissions\Authority;
41use MediaWiki\Revision\RevisionRecord;
42use MediaWiki\SpecialPage\SpecialPage;
43use MediaWiki\Title\Title;
44use MediaWiki\Title\TitleValue;
45use MediaWiki\User\ExternalUserNames;
46use MediaWiki\User\UserIdentityValue;
47use MediaWiki\Xml\Xml;
48use MessageLocalizer;
49use Wikimedia\Assert\Assert;
50use Wikimedia\Rdbms\SelectQueryBuilder;
51use Wikimedia\RemexHtml\Serializer\SerializerNode;
52
53/**
54 * Some internal bits split of from Skin.php. These functions are used
55 * for primarily page content: links, embedded images, table of contents. Links
56 * are also used in the skin.
57 *
58 * @todo turn this into a legacy interface for HtmlPageLinkRenderer and similar services.
59 *
60 * @ingroup Skins
61 */
62class Linker {
63    /**
64     * Flags for userToolLinks()
65     */
66    public const TOOL_LINKS_NOBLOCK = 1;
67    public const TOOL_LINKS_EMAIL = 2;
68
69    /**
70     * This function returns an HTML link to the given target.  It serves a few
71     * purposes:
72     *   1) If $target is a LinkTarget, the correct URL to link to will be figured
73     *      out automatically.
74     *   2) It automatically adds the usual classes for various types of link
75     *      targets: "new" for red links, "stub" for short articles, etc.
76     *   3) It escapes all attribute values safely so there's no risk of XSS.
77     *   4) It provides a default tooltip if the target is a LinkTarget (the page
78     *      name of the target).
79     * link() replaces the old functions in the makeLink() family.
80     *
81     * @since 1.18 Method exists since 1.16 as non-static, made static in 1.18.
82     * @deprecated since 1.28, use MediaWiki\Linker\LinkRenderer instead
83     *
84     * @param LinkTarget $target Can currently only be a LinkTarget, but this may
85     *   change to support Images, literal URLs, etc.
86     * @param string|null $html The HTML contents of the <a> element, i.e.,
87     *   the link text.  This is raw HTML and will not be escaped.  If null,
88     *   defaults to the prefixed text of the LinkTarget; or if the LinkTarget is just a
89     *   fragment, the contents of the fragment.
90     * @param array $customAttribs A key => value array of extra HTML attributes,
91     *   such as title and class.  (href is ignored.)  Classes will be
92     *   merged with the default classes, while other attributes will replace
93     *   default attributes.  All passed attribute values will be HTML-escaped.
94     *   A false attribute value means to suppress that attribute.
95     * @param array $query The query string to append to the URL
96     *   you're linking to, in key => value array form.  Query keys and values
97     *   will be URL-encoded.
98     * @param string|array $options String or array of strings:
99     *     'known': Page is known to exist, so don't check if it does.
100     *     'broken': Page is known not to exist, so don't check if it does.
101     *     'noclasses': Don't add any classes automatically (includes "new",
102     *       "stub", "mw-redirect", "extiw").  Only use the class attribute
103     *       provided, if any, so you get a simple blue link with no icons.
104     *     'forcearticlepath': Use the article path always, even with a querystring.
105     *       Has compatibility issues on some setups, so avoid wherever possible.
106     *     'http': Force a full URL with http:// as the scheme.
107     *     'https': Force a full URL with https:// as the scheme.
108     * @return string HTML <a> attribute
109     */
110    public static function link(
111        $target, $html = null, $customAttribs = [], $query = [], $options = []
112    ) {
113        if ( !$target instanceof LinkTarget ) {
114            wfWarn( __METHOD__ . ': Requires $target to be a LinkTarget object.', 2 );
115            return "<!-- ERROR -->$html";
116        }
117
118        $services = MediaWikiServices::getInstance();
119        $options = (array)$options;
120        if ( $options ) {
121            // Custom options, create new LinkRenderer
122            $linkRenderer = $services->getLinkRendererFactory()
123                ->createFromLegacyOptions( $options );
124        } else {
125            $linkRenderer = $services->getLinkRenderer();
126        }
127
128        if ( $html !== null ) {
129            $text = new HtmlArmor( $html );
130        } else {
131            $text = null;
132        }
133
134        if ( in_array( 'known', $options, true ) ) {
135            return $linkRenderer->makeKnownLink( $target, $text, $customAttribs, $query );
136        }
137
138        if ( in_array( 'broken', $options, true ) ) {
139            return $linkRenderer->makeBrokenLink( $target, $text, $customAttribs, $query );
140        }
141
142        if ( in_array( 'noclasses', $options, true ) ) {
143            return $linkRenderer->makePreloadedLink( $target, $text, '', $customAttribs, $query );
144        }
145
146        return $linkRenderer->makeLink( $target, $text, $customAttribs, $query );
147    }
148
149    /**
150     * Identical to link(), except $options defaults to 'known'.
151     *
152     * @since 1.16.3
153     * @deprecated since 1.28, use MediaWiki\Linker\LinkRenderer instead
154     * @see Linker::link
155     * @param LinkTarget $target
156     * @param-taint $target none
157     * @param string|null $html
158     * @param-taint $html exec_html
159     * @param array $customAttribs
160     * @param-taint $customAttribs none
161     * @param array $query
162     * @param-taint $query none
163     * @param string|array $options
164     * @param-taint $options none
165     * @return string
166     * @return-taint escaped
167     */
168    public static function linkKnown(
169        $target, $html = null, $customAttribs = [],
170        $query = [], $options = [ 'known' ]
171    ) {
172        return self::link( $target, $html, $customAttribs, $query, $options );
173    }
174
175    /**
176     * Make appropriate markup for a link to the current article. This is since
177     * MediaWiki 1.29.0 rendered as an <a> tag without an href and with a class
178     * showing the link text. The calling sequence is the same as for the other
179     * make*LinkObj static functions, but $query is not used.
180     *
181     * @since 1.16.3
182     * @param LinkTarget $nt
183     * @param string $html
184     * @param string $query
185     * @param string $trail
186     * @param string $prefix
187     * @param string $hash hash fragment since 1.40. Should be properly escaped using
188     *   Sanitizer::escapeIdForLink before being passed to this function.
189     *
190     * @return string
191     */
192    public static function makeSelfLinkObj( $nt, $html = '', $query = '', $trail = '', $prefix = '', $hash = '' ) {
193        $nt = Title::newFromLinkTarget( $nt );
194        $attrs = [];
195        if ( $hash ) {
196            $attrs['class'] = 'mw-selflink-fragment';
197            $attrs['href'] = '#' . $hash;
198        } else {
199            // For backwards compatibility with gadgets we add selflink as well.
200            $attrs['class'] = 'mw-selflink selflink';
201        }
202        $ret = Html::rawElement( 'a', $attrs, $prefix . $html ) . $trail;
203        $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
204        if ( !$hookRunner->onSelfLinkBegin( $nt, $html, $trail, $prefix, $ret ) ) {
205            return $ret;
206        }
207
208        if ( $html == '' ) {
209            $html = htmlspecialchars( $nt->getPrefixedText() );
210        }
211        [ $inside, $trail ] = self::splitTrail( $trail );
212        return Html::rawElement( 'a', $attrs, $prefix . $html . $inside ) . $trail;
213    }
214
215    /**
216     * Get a message saying that an invalid title was encountered.
217     * This should be called after a method like Title::makeTitleSafe() returned
218     * a value indicating that the title object is invalid.
219     *
220     * @param IContextSource $context Context to use to get the messages
221     * @param int $namespace Namespace number
222     * @param string $title Text of the title, without the namespace part
223     * @return string
224     */
225    public static function getInvalidTitleDescription( IContextSource $context, $namespace, $title ) {
226        // First we check whether the namespace exists or not.
227        if ( MediaWikiServices::getInstance()->getNamespaceInfo()->exists( $namespace ) ) {
228            if ( $namespace == NS_MAIN ) {
229                $name = $context->msg( 'blanknamespace' )->text();
230            } else {
231                $name = MediaWikiServices::getInstance()->getContentLanguage()->
232                    getFormattedNsText( $namespace );
233            }
234            return $context->msg( 'invalidtitle-knownnamespace', $namespace, $name, $title )->text();
235        }
236
237        return $context->msg( 'invalidtitle-unknownnamespace', $namespace, $title )->text();
238    }
239
240    /**
241     * Returns the filename part of an url.
242     * Used as alternative text for external images.
243     *
244     * @param string $url
245     *
246     * @return string
247     */
248    private static function fnamePart( $url ) {
249        $basename = strrchr( $url, '/' );
250        if ( $basename === false ) {
251            $basename = $url;
252        } else {
253            $basename = substr( $basename, 1 );
254        }
255        return $basename;
256    }
257
258    /**
259     * Return the code for images which were added via external links,
260     * via Parser::maybeMakeExternalImage().
261     *
262     * @since 1.16.3
263     * @param string $url
264     * @param string $alt
265     *
266     * @return string
267     */
268    public static function makeExternalImage( $url, $alt = '' ) {
269        if ( $alt == '' ) {
270            $alt = self::fnamePart( $url );
271        }
272        $img = '';
273        $success = ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
274            ->onLinkerMakeExternalImage( $url, $alt, $img );
275        if ( !$success ) {
276            wfDebug( "Hook LinkerMakeExternalImage changed the output of external image "
277                . "with url {$url} and alt text {$alt} to {$img}" );
278            return $img;
279        }
280        return Html::element( 'img',
281            [
282                'src' => $url,
283                'alt' => $alt
284            ]
285        );
286    }
287
288    /**
289     * Given parameters derived from [[Image:Foo|options...]], generate the
290     * HTML that that syntax inserts in the page.
291     *
292     * @param Parser $parser
293     * @param LinkTarget $title LinkTarget object of the file (not the currently viewed page)
294     * @param File|false $file File object, or false if it doesn't exist
295     * @param array $frameParams Associative array of parameters external to the media handler.
296     *     Boolean parameters are indicated by presence or absence, the value is arbitrary and
297     *     will often be false.
298     *          thumbnail       If present, downscale and frame
299     *          manualthumb     Image name to use as a thumbnail, instead of automatic scaling
300     *          framed          Shows image in original size in a frame
301     *          frameless       Downscale but don't frame
302     *          upright         If present, tweak default sizes for portrait orientation
303     *          upright_factor  Fudge factor for "upright" tweak (default 0.75)
304     *          border          If present, show a border around the image
305     *          align           Horizontal alignment (left, right, center, none)
306     *          valign          Vertical alignment (baseline, sub, super, top, text-top, middle,
307     *                          bottom, text-bottom)
308     *          alt             Alternate text for image (i.e. alt attribute). Plain text.
309     *          title           Used for tooltips if caption isn't visible.
310     *          class           HTML for image classes. Plain text.
311     *          caption         HTML for image caption.
312     *          link-url        URL to link to
313     *          link-title      LinkTarget object to link to
314     *          link-target     Value for the target attribute, only with link-url
315     *          no-link         Boolean, suppress description link
316     *
317     * @param array $handlerParams Associative array of media handler parameters, to be passed
318     *       to transform(). Typical keys are "width" and "page".
319     *          targetlang      (optional) Target language code, see Parser::getTargetLanguage()
320     * @param string|false $time Timestamp of the file, set as false for current
321     * @param string $query Query params for desc url
322     * @param int|null $widthOption Used by the parser to remember the user preference thumbnailsize
323     * @since 1.20
324     * @return string HTML for an image, with links, wrappers, etc.
325     */
326    public static function makeImageLink( Parser $parser, LinkTarget $title,
327        $file, $frameParams = [], $handlerParams = [], $time = false,
328        $query = '', $widthOption = null
329    ) {
330        $title = Title::newFromLinkTarget( $title );
331        $res = null;
332        $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
333        if ( !$hookRunner->onImageBeforeProduceHTML( null, $title,
334            // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
335            $file, $frameParams, $handlerParams, $time, $res,
336            // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
337            $parser, $query, $widthOption )
338        ) {
339            return $res;
340        }
341
342        if ( $file && !$file->allowInlineDisplay() ) {
343            wfDebug( __METHOD__ . ': ' . $title->getPrefixedDBkey() . ' does not allow inline display' );
344            return self::link( $title );
345        }
346
347        // Clean up parameters
348        $page = $handlerParams['page'] ?? false;
349        if ( !isset( $frameParams['align'] ) ) {
350            $frameParams['align'] = '';
351        }
352        if ( !isset( $frameParams['title'] ) ) {
353            $frameParams['title'] = '';
354        }
355        if ( !isset( $frameParams['class'] ) ) {
356            $frameParams['class'] = '';
357        }
358
359        $services = MediaWikiServices::getInstance();
360        $config = $services->getMainConfig();
361        $enableLegacyMediaDOM = $config->get( MainConfigNames::ParserEnableLegacyMediaDOM );
362
363        $classes = [];
364        if (
365            !isset( $handlerParams['width'] ) &&
366            !isset( $frameParams['manualthumb'] ) &&
367            !isset( $frameParams['framed'] )
368        ) {
369            $classes[] = 'mw-default-size';
370        }
371
372        $prefix = $postfix = '';
373
374        if ( $enableLegacyMediaDOM ) {
375            if ( $frameParams['align'] == 'center' ) {
376                $prefix = '<div class="center">';
377                $postfix = '</div>';
378                $frameParams['align'] = 'none';
379            }
380        }
381
382        if ( $file && !isset( $handlerParams['width'] ) ) {
383            if ( isset( $handlerParams['height'] ) && $file->isVectorized() ) {
384                // If its a vector image, and user only specifies height
385                // we don't want it to be limited by its "normal" width.
386                $svgMaxSize = $config->get( MainConfigNames::SVGMaxSize );
387                $handlerParams['width'] = $svgMaxSize;
388            } else {
389                $handlerParams['width'] = $file->getWidth( $page );
390            }
391
392            if ( isset( $frameParams['thumbnail'] )
393                || isset( $frameParams['manualthumb'] )
394                || isset( $frameParams['framed'] )
395                || isset( $frameParams['frameless'] )
396                || !$handlerParams['width']
397            ) {
398                $thumbLimits = $config->get( MainConfigNames::ThumbLimits );
399                $thumbUpright = $config->get( MainConfigNames::ThumbUpright );
400                if ( $widthOption === null || !isset( $thumbLimits[$widthOption] ) ) {
401                    $userOptionsLookup = $services->getUserOptionsLookup();
402                    $widthOption = $userOptionsLookup->getDefaultOption( 'thumbsize' );
403                }
404
405                // Reduce width for upright images when parameter 'upright' is used
406                if ( isset( $frameParams['upright'] ) && $frameParams['upright'] == 0 ) {
407                    $frameParams['upright'] = $thumbUpright;
408                }
409
410                // For caching health: If width scaled down due to upright
411                // parameter, round to full __0 pixel to avoid the creation of a
412                // lot of odd thumbs.
413                $prefWidth = isset( $frameParams['upright'] ) ?
414                    round( $thumbLimits[$widthOption] * $frameParams['upright'], -1 ) :
415                    $thumbLimits[$widthOption];
416
417                // Use width which is smaller: real image width or user preference width
418                // Unless image is scalable vector.
419                if ( !isset( $handlerParams['height'] ) && ( $handlerParams['width'] <= 0 ||
420                        $prefWidth < $handlerParams['width'] || $file->isVectorized() ) ) {
421                    $handlerParams['width'] = $prefWidth;
422                }
423            }
424        }
425
426        // Parser::makeImage has a similarly named variable
427        $hasVisibleCaption = isset( $frameParams['thumbnail'] ) ||
428            isset( $frameParams['manualthumb'] ) ||
429            isset( $frameParams['framed'] );
430
431        if ( $hasVisibleCaption ) {
432            if ( $enableLegacyMediaDOM ) {
433                // This is no longer needed in our new media output, since the
434                // default styling in content.media-common.less takes care of it;
435                // see T269704.
436
437                # Create a thumbnail. Alignment depends on the writing direction of
438                # the page content language (right-aligned for LTR languages,
439                # left-aligned for RTL languages)
440                # If a thumbnail width has not been provided, it is set
441                # to the default user option as specified in Language*.php
442                if ( $frameParams['align'] == '' ) {
443                    $frameParams['align'] = $parser->getTargetLanguage()->alignEnd();
444                }
445            }
446            return $prefix . self::makeThumbLink2(
447                $title, $file, $frameParams, $handlerParams, $time, $query,
448                $classes, $parser
449            ) . $postfix;
450        }
451
452        $rdfaType = 'mw:File';
453
454        if ( isset( $frameParams['frameless'] ) ) {
455            $rdfaType .= '/Frameless';
456            if ( $file ) {
457                $srcWidth = $file->getWidth( $page );
458                # For "frameless" option: do not present an image bigger than the
459                # source (for bitmap-style images). This is the same behavior as the
460                # "thumb" option does it already.
461                if ( $srcWidth && !$file->mustRender() && $handlerParams['width'] > $srcWidth ) {
462                    $handlerParams['width'] = $srcWidth;
463                }
464            }
465        }
466
467        if ( $file && isset( $handlerParams['width'] ) ) {
468            # Create a resized image, without the additional thumbnail features
469            $thumb = $file->transform( $handlerParams );
470        } else {
471            $thumb = false;
472        }
473
474        $isBadFile = $file && $thumb &&
475            $parser->getBadFileLookup()->isBadFile( $title->getDBkey(), $parser->getTitle() );
476
477        if ( !$thumb || ( !$enableLegacyMediaDOM && $thumb->isError() ) || $isBadFile ) {
478            $rdfaType = 'mw:Error ' . $rdfaType;
479            $currentExists = $file && $file->exists();
480            if ( $enableLegacyMediaDOM ) {
481                $label = $frameParams['title'];
482            } else {
483                if ( $currentExists && !$thumb ) {
484                    $label = wfMessage( 'thumbnail_error', '' )->text();
485                } elseif ( $thumb && $thumb->isError() ) {
486                    Assert::invariant(
487                        $thumb instanceof MediaTransformError,
488                        'Unknown MediaTransformOutput: ' . get_class( $thumb )
489                    );
490                    $label = $thumb->toText();
491                } else {
492                    $label = $frameParams['alt'] ?? '';
493                }
494            }
495            $s = self::makeBrokenImageLinkObj(
496                $title, $label, '', '', '', (bool)$time, $handlerParams, $currentExists
497            );
498        } else {
499            self::processResponsiveImages( $file, $thumb, $handlerParams );
500            $params = [];
501            // An empty alt indicates an image is not a key part of the content
502            // and that non-visual browsers may omit it from rendering.  Only
503            // set the parameter if it's explicitly requested.
504            if ( isset( $frameParams['alt'] ) ) {
505                $params['alt'] = $frameParams['alt'];
506            }
507            $params['title'] = $frameParams['title'];
508            if ( $enableLegacyMediaDOM ) {
509                $params += [
510                    'valign' => $frameParams['valign'] ?? false,
511                    'img-class' => $frameParams['class'],
512                ];
513                if ( isset( $frameParams['border'] ) ) {
514                    $params['img-class'] .= ( $params['img-class'] !== '' ? ' ' : '' ) . 'thumbborder';
515                }
516            } else {
517                $params += [
518                    'img-class' => 'mw-file-element',
519                ];
520            }
521            $params = self::getImageLinkMTOParams( $frameParams, $query, $parser ) + $params;
522            $s = $thumb->toHtml( $params );
523        }
524
525        if ( $enableLegacyMediaDOM ) {
526            if ( $frameParams['align'] != '' ) {
527                $s = Html::rawElement(
528                    'div',
529                    [ 'class' => 'float' . $frameParams['align'] ],
530                    $s
531                );
532            }
533            return str_replace( "\n", ' ', $prefix . $s . $postfix );
534        }
535
536        $wrapper = 'span';
537        $caption = '';
538
539        if ( $frameParams['align'] != '' ) {
540            $wrapper = 'figure';
541            // Possible values: mw-halign-left mw-halign-center mw-halign-right mw-halign-none
542            $classes[] = "mw-halign-{$frameParams['align']}";
543            $caption = Html::rawElement(
544                'figcaption', [], $frameParams['caption'] ?? ''
545            );
546        } elseif ( isset( $frameParams['valign'] ) ) {
547            // Possible values: mw-valign-middle mw-valign-baseline mw-valign-sub
548            // mw-valign-super mw-valign-top mw-valign-text-top mw-valign-bottom
549            // mw-valign-text-bottom
550            $classes[] = "mw-valign-{$frameParams['valign']}";
551        }
552
553        if ( isset( $frameParams['border'] ) ) {
554            $classes[] = 'mw-image-border';
555        }
556
557        if ( isset( $frameParams['class'] ) ) {
558            $classes[] = $frameParams['class'];
559        }
560
561        $attribs = [
562            'class' => $classes,
563            'typeof' => $rdfaType,
564        ];
565
566        $s = Html::rawElement( $wrapper, $attribs, $s . $caption );
567
568        return str_replace( "\n", ' ', $s );
569    }
570
571    /**
572     * Get the link parameters for MediaTransformOutput::toHtml() from given
573     * frame parameters supplied by the Parser.
574     * @param array $frameParams The frame parameters
575     * @param string $query An optional query string to add to description page links
576     * @param Parser|null $parser
577     * @return array
578     */
579    public static function getImageLinkMTOParams( $frameParams, $query = '', $parser = null ) {
580        $mtoParams = [];
581        if ( isset( $frameParams['link-url'] ) && $frameParams['link-url'] !== '' ) {
582            $mtoParams['custom-url-link'] = $frameParams['link-url'];
583            if ( isset( $frameParams['link-target'] ) ) {
584                $mtoParams['custom-target-link'] = $frameParams['link-target'];
585            }
586            if ( $parser ) {
587                $extLinkAttrs = $parser->getExternalLinkAttribs( $frameParams['link-url'] );
588                foreach ( $extLinkAttrs as $name => $val ) {
589                    // Currently could include 'rel' and 'target'
590                    $mtoParams['parser-extlink-' . $name] = $val;
591                }
592            }
593        } elseif ( isset( $frameParams['link-title'] ) && $frameParams['link-title'] !== '' ) {
594            $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
595            $mtoParams['custom-title-link'] = Title::newFromLinkTarget(
596                $linkRenderer->normalizeTarget( $frameParams['link-title'] )
597            );
598            if ( isset( $frameParams['link-title-query'] ) ) {
599                $mtoParams['custom-title-link-query'] = $frameParams['link-title-query'];
600            }
601        } elseif ( !empty( $frameParams['no-link'] ) ) {
602            // No link
603        } else {
604            $mtoParams['desc-link'] = true;
605            $mtoParams['desc-query'] = $query;
606        }
607        return $mtoParams;
608    }
609
610    /**
611     * Make HTML for a thumbnail including image, border and caption
612     * @param LinkTarget $title
613     * @param File|false $file File object or false if it doesn't exist
614     * @param string $label
615     * @param string $alt
616     * @param string|null $align
617     * @param array $params
618     * @param bool $framed
619     * @param string $manualthumb
620     * @return string
621     */
622    public static function makeThumbLinkObj(
623        LinkTarget $title, $file, $label = '', $alt = '', $align = null,
624        $params = [], $framed = false, $manualthumb = ''
625    ) {
626        $frameParams = [
627            'alt' => $alt,
628            'caption' => $label,
629            'align' => $align
630        ];
631        $classes = [];
632        if ( $manualthumb ) {
633            $frameParams['manualthumb'] = $manualthumb;
634        } elseif ( $framed ) {
635            $frameParams['framed'] = true;
636        } elseif ( !isset( $params['width'] ) ) {
637            $classes[] = 'mw-default-size';
638        }
639        return self::makeThumbLink2(
640            $title, $file, $frameParams, $params, false, '', $classes
641        );
642    }
643
644    /**
645     * @param LinkTarget $title
646     * @param File|false $file
647     * @param array $frameParams
648     * @param array $handlerParams
649     * @param bool $time If a file of a certain timestamp was requested
650     * @param string $query
651     * @param string[] $classes @since 1.36
652     * @param Parser|null $parser @since 1.38
653     * @return string
654     */
655    public static function makeThumbLink2(
656        LinkTarget $title, $file, $frameParams = [], $handlerParams = [],
657        $time = false, $query = '', array $classes = [], ?Parser $parser = null
658    ) {
659        $exists = $file && $file->exists();
660
661        $services = MediaWikiServices::getInstance();
662        $enableLegacyMediaDOM = $services->getMainConfig()->get( MainConfigNames::ParserEnableLegacyMediaDOM );
663
664        $page = $handlerParams['page'] ?? false;
665        $lang = $handlerParams['lang'] ?? false;
666
667        if ( !isset( $frameParams['align'] ) ) {
668            $frameParams['align'] = '';
669            if ( $enableLegacyMediaDOM ) {
670                $frameParams['align'] = 'right';
671            }
672        }
673        if ( !isset( $frameParams['caption'] ) ) {
674            $frameParams['caption'] = '';
675        }
676
677        if ( empty( $handlerParams['width'] ) ) {
678            // Reduce width for upright images when parameter 'upright' is used
679            $handlerParams['width'] = isset( $frameParams['upright'] ) ? 130 : 180;
680        }
681
682        $thumb = false;
683        $noscale = false;
684        $manualthumb = false;
685        $manual_title = '';
686        $rdfaType = 'mw:File/Thumb';
687
688        if ( !$exists ) {
689            // Same precedence as the $exists case
690            if ( !isset( $frameParams['manualthumb'] ) && isset( $frameParams['framed'] ) ) {
691                $rdfaType = 'mw:File/Frame';
692            }
693            $outerWidth = $handlerParams['width'] + 2;
694        } else {
695            if ( isset( $frameParams['manualthumb'] ) ) {
696                # Use manually specified thumbnail
697                $manual_title = Title::makeTitleSafe( NS_FILE, $frameParams['manualthumb'] );
698                if ( $manual_title ) {
699                    $manual_img = $services->getRepoGroup()
700                        ->findFile( $manual_title );
701                    if ( $manual_img ) {
702                        $thumb = $manual_img->getUnscaledThumb( $handlerParams );
703                        $manualthumb = true;
704                    }
705                }
706            } else {
707                $srcWidth = $file->getWidth( $page );
708                if ( isset( $frameParams['framed'] ) ) {
709                    $rdfaType = 'mw:File/Frame';
710                    if ( !$file->isVectorized() ) {
711                        // Use image dimensions, don't scale
712                        $noscale = true;
713                    } else {
714                        // framed is unscaled, but for vectorized images
715                        // we need to a width for scaling up for the high density variants
716                        $handlerParams['width'] = $srcWidth;
717                    }
718                }
719
720                // Do not present an image bigger than the source, for bitmap-style images
721                // This is a hack to maintain compatibility with arbitrary pre-1.10 behavior
722                if ( $srcWidth && !$file->mustRender() && $handlerParams['width'] > $srcWidth ) {
723                    $handlerParams['width'] = $srcWidth;
724                }
725
726                $thumb = $noscale
727                    ? $file->getUnscaledThumb( $handlerParams )
728                    : $file->transform( $handlerParams );
729            }
730
731            if ( $thumb ) {
732                $outerWidth = $thumb->getWidth() + 2;
733            } else {
734                $outerWidth = $handlerParams['width'] + 2;
735            }
736        }
737
738        if ( !$enableLegacyMediaDOM && $parser && $rdfaType === 'mw:File/Thumb' ) {
739            $parser->getOutput()->addModules( [ 'mediawiki.page.media' ] );
740        }
741
742        $url = Title::newFromLinkTarget( $title )->getLocalURL( $query );
743        $linkTitleQuery = [];
744        if ( $page || $lang ) {
745            if ( $page ) {
746                $linkTitleQuery['page'] = $page;
747            }
748            if ( $lang ) {
749                $linkTitleQuery['lang'] = $lang;
750            }
751            # ThumbnailImage::toHtml() already adds page= onto the end of DjVu URLs
752            # So we don't need to pass it here in $query. However, the URL for the
753            # zoom icon still needs it, so we make a unique query for it. See T16771
754            $url = wfAppendQuery( $url, $linkTitleQuery );
755        }
756
757        if ( $manualthumb
758            && !isset( $frameParams['link-title'] )
759            && !isset( $frameParams['link-url'] )
760            && !isset( $frameParams['no-link'] ) ) {
761            $frameParams['link-title'] = $title;
762            $frameParams['link-title-query'] = $linkTitleQuery;
763        }
764
765        if ( $frameParams['align'] != '' ) {
766            // Possible values: mw-halign-left mw-halign-center mw-halign-right mw-halign-none
767            $classes[] = "mw-halign-{$frameParams['align']}";
768        }
769
770        if ( isset( $frameParams['class'] ) ) {
771            $classes[] = $frameParams['class'];
772        }
773
774        $s = '';
775
776        if ( $enableLegacyMediaDOM ) {
777            $s .= "<div class=\"thumb t{$frameParams['align']}\">"
778                . "<div class=\"thumbinner\" style=\"width:{$outerWidth}px;\">";
779        }
780
781        $isBadFile = $exists && $thumb && $parser &&
782            $parser->getBadFileLookup()->isBadFile(
783                $manualthumb ? $manual_title : $title->getDBkey(),
784                $parser->getTitle()
785            );
786
787        if ( !$exists ) {
788            $rdfaType = 'mw:Error ' . $rdfaType;
789            $label = '';
790            if ( !$enableLegacyMediaDOM ) {
791                $label = $frameParams['alt'] ?? '';
792            }
793            $s .= self::makeBrokenImageLinkObj(
794                $title, $label, '', '', '', (bool)$time, $handlerParams, false
795            );
796            $zoomIcon = '';
797        } elseif ( !$thumb || ( !$enableLegacyMediaDOM && $thumb->isError() ) || $isBadFile ) {
798            $rdfaType = 'mw:Error ' . $rdfaType;
799            if ( $enableLegacyMediaDOM ) {
800                if ( !$thumb ) {
801                    $s .= wfMessage( 'thumbnail_error', '' )->escaped();
802                } else {
803                    $s .= self::makeBrokenImageLinkObj(
804                        $title, '', '', '', '', (bool)$time, $handlerParams, true
805                    );
806                }
807            } else {
808                if ( $thumb && $thumb->isError() ) {
809                    Assert::invariant(
810                        $thumb instanceof MediaTransformError,
811                        'Unknown MediaTransformOutput: ' . get_class( $thumb )
812                    );
813                    $label = $thumb->toText();
814                } elseif ( !$thumb ) {
815                    $label = wfMessage( 'thumbnail_error', '' )->text();
816                } else {
817                    $label = '';
818                }
819                $s .= self::makeBrokenImageLinkObj(
820                    $title, $label, '', '', '', (bool)$time, $handlerParams, true
821                );
822            }
823            $zoomIcon = '';
824        } else {
825            if ( !$noscale && !$manualthumb ) {
826                self::processResponsiveImages( $file, $thumb, $handlerParams );
827            }
828            $params = [];
829            // An empty alt indicates an image is not a key part of the content
830            // and that non-visual browsers may omit it from rendering.  Only
831            // set the parameter if it's explicitly requested.
832            if ( isset( $frameParams['alt'] ) ) {
833                $params['alt'] = $frameParams['alt'];
834            }
835            if ( $enableLegacyMediaDOM ) {
836                $params += [
837                    'img-class' => ( isset( $frameParams['class'] ) && $frameParams['class'] !== ''
838                        ? $frameParams['class'] . ' '
839                        : '' ) . 'thumbimage'
840                ];
841            } else {
842                $params += [
843                    'img-class' => 'mw-file-element',
844                ];
845                // Only thumbs gets the magnify link
846                if ( $rdfaType === 'mw:File/Thumb' ) {
847                    $params['magnify-resource'] = $url;
848                }
849            }
850            $params = self::getImageLinkMTOParams( $frameParams, $query, $parser ) + $params;
851            $s .= $thumb->toHtml( $params );
852            if ( isset( $frameParams['framed'] ) ) {
853                $zoomIcon = '';
854            } else {
855                $zoomIcon = Html::rawElement( 'div', [ 'class' => 'magnify' ],
856                    Html::rawElement( 'a', [
857                        'href' => $url,
858                        'class' => 'internal',
859                        'title' => wfMessage( 'thumbnail-more' )->text(),
860                    ] )
861                );
862            }
863        }
864
865        if ( $enableLegacyMediaDOM ) {
866            $s .= '  <div class="thumbcaption">' . $zoomIcon . $frameParams['caption'] . '</div></div></div>';
867            return str_replace( "\n", ' ', $s );
868        }
869
870        $s .= Html::rawElement(
871            'figcaption', [], $frameParams['caption'] ?? ''
872        );
873
874        $attribs = [
875            'class' => $classes,
876            'typeof' => $rdfaType,
877        ];
878
879        $s = Html::rawElement( 'figure', $attribs, $s );
880
881        return str_replace( "\n", ' ', $s );
882    }
883
884    /**
885     * Process responsive images: add 1.5x and 2x subimages to the thumbnail, where
886     * applicable.
887     *
888     * @param File $file
889     * @param MediaTransformOutput|null $thumb
890     * @param array $hp Image parameters
891     */
892    public static function processResponsiveImages( $file, $thumb, $hp ) {
893        $responsiveImages = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::ResponsiveImages );
894        if ( $responsiveImages && $thumb && !$thumb->isError() ) {
895            $hp15 = $hp;
896            $hp15['width'] = round( $hp['width'] * 1.5 );
897            $hp20 = $hp;
898            $hp20['width'] = $hp['width'] * 2;
899            if ( isset( $hp['height'] ) ) {
900                $hp15['height'] = round( $hp['height'] * 1.5 );
901                $hp20['height'] = $hp['height'] * 2;
902            }
903
904            $thumb15 = $file->transform( $hp15 );
905            $thumb20 = $file->transform( $hp20 );
906            if ( $thumb15 && !$thumb15->isError() && $thumb15->getUrl() !== $thumb->getUrl() ) {
907                $thumb->responsiveUrls['1.5'] = $thumb15->getUrl();
908            }
909            if ( $thumb20 && !$thumb20->isError() && $thumb20->getUrl() !== $thumb->getUrl() ) {
910                $thumb->responsiveUrls['2'] = $thumb20->getUrl();
911            }
912        }
913    }
914
915    /**
916     * Make a "broken" link to an image
917     *
918     * @since 1.16.3
919     * @param LinkTarget $title
920     * @param string $label Link label (plain text)
921     * @param string $query Query string
922     * @param string $unused1 Unused parameter kept for b/c
923     * @param string $unused2 Unused parameter kept for b/c
924     * @param bool $time A file of a certain timestamp was requested
925     * @param array $handlerParams @since 1.36
926     * @param bool $currentExists @since 1.41
927     * @return string
928     */
929    public static function makeBrokenImageLinkObj(
930        $title, $label = '', $query = '', $unused1 = '', $unused2 = '',
931        $time = false, array $handlerParams = [], bool $currentExists = false
932    ) {
933        if ( !$title instanceof LinkTarget ) {
934            wfWarn( __METHOD__ . ': Requires $title to be a LinkTarget object.' );
935            return "<!-- ERROR -->" . htmlspecialchars( $label );
936        }
937
938        $title = Title::newFromLinkTarget( $title );
939        $services = MediaWikiServices::getInstance();
940        $mainConfig = $services->getMainConfig();
941        $enableUploads = $mainConfig->get( MainConfigNames::EnableUploads );
942        $uploadMissingFileUrl = $mainConfig->get( MainConfigNames::UploadMissingFileUrl );
943        $uploadNavigationUrl = $mainConfig->get( MainConfigNames::UploadNavigationUrl );
944        if ( $label == '' ) {
945            $label = $title->getPrefixedText();
946        }
947
948        $html = Html::element( 'span', [
949            'class' => 'mw-file-element mw-broken-media',
950            // These data attributes are used to dynamically size the span, see T273013
951            'data-width' => $handlerParams['width'] ?? null,
952            'data-height' => $handlerParams['height'] ?? null,
953        ], $label );
954
955        if ( $mainConfig->get( MainConfigNames::ParserEnableLegacyMediaDOM ) ) {
956            $html = htmlspecialchars( $label, ENT_COMPAT );
957        }
958
959        $repoGroup = $services->getRepoGroup();
960        $currentExists = $currentExists ||
961            ( $time && $repoGroup->findFile( $title ) !== false );
962
963        if ( ( $uploadMissingFileUrl || $uploadNavigationUrl || $enableUploads )
964            && !$currentExists
965        ) {
966            if (
967                $title->inNamespace( NS_FILE ) &&
968                $repoGroup->getLocalRepo()->checkRedirect( $title )
969            ) {
970                // We already know it's a redirect, so mark it accordingly
971                return self::link(
972                    $title,
973                    $html,
974                    [ 'class' => 'mw-redirect' ],
975                    wfCgiToArray( $query ),
976                    [ 'known', 'noclasses' ]
977                );
978            }
979            return Html::rawElement( 'a', [
980                    'href' => self::getUploadUrl( $title, $query ),
981                    'class' => 'new',
982                    'title' => $title->getPrefixedText()
983                ], $html );
984        }
985        return self::link(
986            $title,
987            $html,
988            [],
989            wfCgiToArray( $query ),
990            [ 'known', 'noclasses' ]
991        );
992    }
993
994    /**
995     * Get the URL to upload a certain file
996     *
997     * @since 1.16.3
998     * @param LinkTarget $destFile LinkTarget object of the file to upload
999     * @param string $query Urlencoded query string to prepend
1000     * @return string Urlencoded URL
1001     */
1002    public static function getUploadUrl( $destFile, $query = '' ) {
1003        $mainConfig = MediaWikiServices::getInstance()->getMainConfig();
1004        $uploadMissingFileUrl = $mainConfig->get( MainConfigNames::UploadMissingFileUrl );
1005        $uploadNavigationUrl = $mainConfig->get( MainConfigNames::UploadNavigationUrl );
1006        $q = 'wpDestFile=' . Title::newFromLinkTarget( $destFile )->getPartialURL();
1007        if ( $query != '' ) {
1008            $q .= '&' . $query;
1009        }
1010
1011        if ( $uploadMissingFileUrl ) {
1012            return wfAppendQuery( $uploadMissingFileUrl, $q );
1013        }
1014
1015        if ( $uploadNavigationUrl ) {
1016            return wfAppendQuery( $uploadNavigationUrl, $q );
1017        }
1018
1019        $upload = SpecialPage::getTitleFor( 'Upload' );
1020
1021        return $upload->getLocalURL( $q );
1022    }
1023
1024    /**
1025     * Create a direct link to a given uploaded file.
1026     *
1027     * @since 1.16.3
1028     * @param LinkTarget $title
1029     * @param string $html Pre-sanitized HTML
1030     * @param string|false $time MW timestamp of file creation time
1031     * @return string HTML
1032     */
1033    public static function makeMediaLinkObj( $title, $html = '', $time = false ) {
1034        $img = MediaWikiServices::getInstance()->getRepoGroup()->findFile(
1035            $title, [ 'time' => $time ]
1036        );
1037        return self::makeMediaLinkFile( $title, $img, $html );
1038    }
1039
1040    /**
1041     * Create a direct link to a given uploaded file.
1042     * This will make a broken link if $file is false.
1043     *
1044     * @since 1.16.3
1045     * @param LinkTarget $title
1046     * @param File|false $file File object or false
1047     * @param string $html Pre-sanitized HTML
1048     * @return string HTML
1049     *
1050     * @todo Handle invalid or missing images better.
1051     */
1052    public static function makeMediaLinkFile( LinkTarget $title, $file, $html = '' ) {
1053        if ( $file && $file->exists() ) {
1054            $url = $file->getUrl();
1055            $class = 'internal';
1056        } else {
1057            $url = self::getUploadUrl( $title );
1058            $class = 'new';
1059        }
1060
1061        $alt = $title->getText();
1062        if ( $html == '' ) {
1063            $html = $alt;
1064        }
1065
1066        $ret = '';
1067        $attribs = [
1068            'href' => $url,
1069            'class' => $class,
1070            'title' => $alt
1071        ];
1072
1073        if ( !( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )->onLinkerMakeMediaLinkFile(
1074            Title::newFromLinkTarget( $title ), $file, $html, $attribs, $ret )
1075        ) {
1076            wfDebug( "Hook LinkerMakeMediaLinkFile changed the output of link "
1077                . "with url {$url} and text {$html} to {$ret}" );
1078            return $ret;
1079        }
1080
1081        return Html::rawElement( 'a', $attribs, $html );
1082    }
1083
1084    /**
1085     * Make a link to a special page given its name and, optionally,
1086     * a message key from the link text.
1087     * Usage example: Linker::specialLink( 'Recentchanges' )
1088     *
1089     * @since 1.16.3
1090     * @param string $name Special page name, can optionally include â€¦/subpages and â€¦?parameters
1091     * @param string $key Optional message key if different from $name
1092     * @return string
1093     */
1094    public static function specialLink( $name, $key = '' ) {
1095        $queryPos = strpos( $name, '?' );
1096        if ( $queryPos !== false ) {
1097            $getParams = wfCgiToArray( substr( $name, $queryPos + 1 ) );
1098            $name = substr( $name, 0, $queryPos );
1099        } else {
1100            $getParams = [];
1101        }
1102
1103        $slashPos = strpos( $name, '/' );
1104        if ( $slashPos !== false ) {
1105            $subpage = substr( $name, $slashPos + 1 );
1106            $name = substr( $name, 0, $slashPos );
1107        } else {
1108            $subpage = false;
1109        }
1110
1111        if ( $key == '' ) {
1112            $key = strtolower( $name );
1113        }
1114
1115        return self::linkKnown(
1116            SpecialPage::getTitleFor( $name, $subpage ),
1117            wfMessage( $key )->escaped(),
1118            [],
1119            $getParams
1120        );
1121    }
1122
1123    /**
1124     * Make an external link
1125     *
1126     * @since 1.16.3. $title added in 1.21
1127     * @param string $url URL to link to
1128     * @param-taint $url escapes_html
1129     * @param string $text Text of link
1130     * @param-taint $text none
1131     * @param bool $escape Do we escape the link text?
1132     * @param-taint $escape none
1133     * @param string $linktype Type of external link. Gets added to the classes
1134     * @param-taint $linktype escapes_html
1135     * @param array $attribs Array of extra attributes to <a>
1136     * @param-taint $attribs escapes_html
1137     * @param LinkTarget|null $title LinkTarget object used for title specific link attributes
1138     * @param-taint $title none
1139     * @return string
1140     * @deprecated since 1.43; use LinkRenderer::makeExternalLink(), passing
1141     *   in an HtmlArmor instance if $escape was false.
1142     */
1143    public static function makeExternalLink( $url, $text, $escape = true,
1144        $linktype = '', $attribs = [], $title = null
1145    ) {
1146        // phpcs:ignore MediaWiki.Usage.DeprecatedGlobalVariables.Deprecated$wgTitle
1147        global $wgTitle;
1148        $linkRenderer = MediaWikiServices::getInstance()->getLinkRenderer();
1149        return $linkRenderer->makeExternalLink(
1150            $url,
1151            $escape ? $text : new HtmlArmor( $text ),
1152            $title ?? $wgTitle ?? SpecialPage::getTitleFor( 'Badtitle' ),
1153            $linktype,
1154            $attribs
1155        );
1156    }
1157
1158    /**
1159     * Make user link (or user contributions for unregistered users)
1160     *
1161     * This method produces HTML that requires CSS styles in mediawiki.interface.helpers.styles.
1162     *
1163     * @param int $userId User id in database.
1164     * @param string $userName User name in database.
1165     * @param string|false $altUserName Text to display instead of the user name (optional)
1166     * @param string[] $attributes Extra HTML attributes. See Linker::link.
1167     * @return string HTML fragment
1168     * @since 1.16.3. $altUserName was added in 1.19. $attributes was added in 1.40.
1169     *
1170     * @deprecated since 1.44, use {@link UserLinkRenderer::userLink()} instead.
1171     */
1172    public static function userLink(
1173        $userId,
1174        $userName,
1175        $altUserName = false,
1176        $attributes = []
1177    ) {
1178        if ( $userName === '' || $userName === false || $userName === null ) {
1179            wfDebug( __METHOD__ . ' received an empty username. Are there database errors ' .
1180                'that need to be fixed?' );
1181            return wfMessage( 'empty-username' )->parse();
1182        }
1183
1184        return MediaWikiServices::getInstance()->getUserLinkRenderer()
1185            ->userLink(
1186                new UserIdentityValue( $userId, (string)$userName ),
1187                RequestContext::getMain(),
1188                $altUserName === false ? null : (string)$altUserName,
1189                $attributes
1190            );
1191    }
1192
1193    /**
1194     * Generate standard user tool links (talk, contributions, block link, etc.)
1195     *
1196     * @since 1.42
1197     * @param int $userId User identifier
1198     * @param string $userText User name or IP address
1199     * @param bool $redContribsWhenNoEdits Should the contributions link be
1200     *   red if the user has no edits?
1201     * @param int $flags Customisation flags (e.g. Linker::TOOL_LINKS_NOBLOCK
1202     *   and Linker::TOOL_LINKS_EMAIL).
1203     * @param int|null $edits User edit count. If you enable $redContribsWhenNoEdits,
1204     *  you may pass a pre-computed edit count here, or 0 if the caller knows that
1205     *  the account has 0 edits. Otherwise, the value is unused and null may
1206     *  be passed. If $redContribsWhenNoEdits is enabled and null is passed, the
1207     *  edit count will be lazily fetched from UserEditTracker.
1208     * @return string[] Array of HTML fragments, each of them a link tag with a distinctive
1209     *   class; or a single string on error.
1210     */
1211    public static function userToolLinkArray(
1212        $userId, $userText, $redContribsWhenNoEdits = false, $flags = 0, $edits = null
1213    ): array {
1214        $services = MediaWikiServices::getInstance();
1215        $disableAnonTalk = $services->getMainConfig()->get( MainConfigNames::DisableAnonTalk );
1216        $talkable = !( $disableAnonTalk && $userId == 0 );
1217        $blockable = !( $flags & self::TOOL_LINKS_NOBLOCK );
1218        $addEmailLink = $flags & self::TOOL_LINKS_EMAIL && $userId;
1219
1220        if ( $userId == 0 && ExternalUserNames::isExternal( $userText ) ) {
1221            // No tools for an external user
1222            return [];
1223        }
1224
1225        $items = [];
1226        if ( $talkable ) {
1227            $items[] = self::userTalkLink( $userId, $userText );
1228        }
1229        if ( $userId ) {
1230            // check if the user has an edit
1231            $attribs = [];
1232            $attribs['class'] = 'mw-usertoollinks-contribs';
1233            if ( $redContribsWhenNoEdits ) {
1234                if ( $edits === null ) {
1235                    $user = UserIdentityValue::newRegistered( $userId, $userText );
1236                    $edits = $services->getUserEditTracker()->getUserEditCount( $user );
1237                }
1238                if ( $edits === 0 ) {
1239                    // Note: "new" class is inappropriate here, as "new" class
1240                    // should only be used for pages that do not exist.
1241                    $attribs['class'] .= ' mw-usertoollinks-contribs-no-edits';
1242                }
1243            }
1244            $contribsPage = SpecialPage::getTitleFor( 'Contributions', $userText );
1245
1246            $items[] = self::link( $contribsPage, wfMessage( 'contribslink' )->escaped(), $attribs );
1247        }
1248        $userCanBlock = RequestContext::getMain()->getAuthority()->isAllowed( 'block' );
1249        if ( $blockable && $userCanBlock ) {
1250            $items[] = self::blockLink( $userId, $userText );
1251        }
1252
1253        if (
1254            $addEmailLink
1255            && MediaWikiServices::getInstance()->getEmailUserFactory()
1256                ->newEmailUser( RequestContext::getMain()->getAuthority() )
1257                ->canSend()
1258                ->isGood()
1259        ) {
1260            $items[] = self::emailLink( $userId, $userText );
1261        }
1262
1263        ( new HookRunner( $services->getHookContainer() ) )->onUserToolLinksEdit( $userId, $userText, $items );
1264
1265        return $items;
1266    }
1267
1268    /**
1269     * Generate standard tool links HTML from a link array returned by userToolLinkArray().
1270     * @since 1.42
1271     * @param array $items
1272     * @param bool $useParentheses (optional, default true) Wrap comments in parentheses where needed
1273     * @return string
1274     */
1275    public static function renderUserToolLinksArray( array $items, bool $useParentheses ): string {
1276        global $wgLang;
1277
1278        if ( !$items ) {
1279            return '';
1280        }
1281
1282        if ( $useParentheses ) {
1283            return wfMessage( 'word-separator' )->escaped()
1284                . '<span class="mw-usertoollinks">'
1285                . wfMessage( 'parentheses' )->rawParams( $wgLang->pipeList( $items ) )->escaped()
1286                . '</span>';
1287        }
1288
1289        $tools = [];
1290        foreach ( $items as $tool ) {
1291            $tools[] = Html::rawElement( 'span', [], $tool );
1292        }
1293        return ' <span class="mw-usertoollinks mw-changeslist-links">' .
1294            implode( ' ', $tools ) . '</span>';
1295    }
1296
1297    /**
1298     * Generate standard user tool links (talk, contributions, block link, etc.)
1299     *
1300     * @since 1.16.3
1301     * @param int $userId User identifier
1302     * @param string $userText User name or IP address
1303     * @param bool $redContribsWhenNoEdits Should the contributions link be
1304     *   red if the user has no edits?
1305     * @param int $flags Customisation flags (e.g. Linker::TOOL_LINKS_NOBLOCK
1306     *   and Linker::TOOL_LINKS_EMAIL).
1307     * @param int|null $edits User edit count (optional, for performance)
1308     * @param bool $useParentheses (optional, default true) Wrap comments in parentheses where needed
1309     * @return string HTML fragment
1310     */
1311    public static function userToolLinks(
1312        $userId, $userText, $redContribsWhenNoEdits = false, $flags = 0, $edits = null,
1313        $useParentheses = true
1314    ) {
1315        if ( $userText === '' ) {
1316            wfDebug( __METHOD__ . ' received an empty username. Are there database errors ' .
1317                'that need to be fixed?' );
1318            return ' ' . wfMessage( 'empty-username' )->parse();
1319        }
1320
1321        $items = self::userToolLinkArray( $userId, $userText, $redContribsWhenNoEdits, $flags, $edits );
1322        return self::renderUserToolLinksArray( $items, $useParentheses );
1323    }
1324
1325    /**
1326     * Alias for userToolLinks( $userId, $userText, true );
1327     * @since 1.16.3
1328     * @param int $userId User identifier
1329     * @param string $userText User name or IP address
1330     * @param int|null $edits User edit count (optional, for performance)
1331     * @param bool $useParentheses (optional) Wrap comments in parentheses where needed
1332     * @return string
1333     */
1334    public static function userToolLinksRedContribs(
1335        $userId, $userText, $edits = null, $useParentheses = true
1336    ) {
1337        return self::userToolLinks( $userId, $userText, true, 0, $edits, $useParentheses );
1338    }
1339
1340    /**
1341     * @since 1.16.3
1342     * @param int $userId User id in database.
1343     * @param string $userText User name in database.
1344     * @return string HTML fragment with user talk link
1345     */
1346    public static function userTalkLink( $userId, $userText ) {
1347        if ( $userText === '' ) {
1348            wfDebug( __METHOD__ . ' received an empty username. Are there database errors ' .
1349                'that need to be fixed?' );
1350            return wfMessage( 'empty-username' )->parse();
1351        }
1352
1353        $userTalkPage = TitleValue::tryNew( NS_USER_TALK, strtr( $userText, ' ', '_' ) );
1354        $moreLinkAttribs = [ 'class' => 'mw-usertoollinks-talk' ];
1355        $linkText = wfMessage( 'talkpagelinktext' )->escaped();
1356
1357        return $userTalkPage
1358            ? self::link( $userTalkPage, $linkText, $moreLinkAttribs )
1359            : Html::rawElement( 'span', $moreLinkAttribs, $linkText );
1360    }
1361
1362    /**
1363     * @since 1.16.3
1364     * @param int $userId
1365     * @param string $userText User name in database.
1366     * @return string HTML fragment with block link
1367     */
1368    public static function blockLink( $userId, $userText ) {
1369        if ( $userText === '' ) {
1370            wfDebug( __METHOD__ . ' received an empty username. Are there database errors ' .
1371                'that need to be fixed?' );
1372            return wfMessage( 'empty-username' )->parse();
1373        }
1374
1375        $blockPage = SpecialPage::getTitleFor( 'Block', $userText );
1376        $moreLinkAttribs = [ 'class' => 'mw-usertoollinks-block' ];
1377
1378        return self::link( $blockPage,
1379            wfMessage( 'blocklink' )->escaped(),
1380            $moreLinkAttribs
1381        );
1382    }
1383
1384    /**
1385     * @param int $userId
1386     * @param string $userText User name in database.
1387     * @return string HTML fragment with e-mail user link
1388     */
1389    public static function emailLink( $userId, $userText ) {
1390        if ( $userText === '' ) {
1391            wfLogWarning( __METHOD__ . ' received an empty username. Are there database errors ' .
1392                'that need to be fixed?' );
1393            return wfMessage( 'empty-username' )->parse();
1394        }
1395
1396        $emailPage = SpecialPage::getTitleFor( 'Emailuser', $userText );
1397        $moreLinkAttribs = [ 'class' => 'mw-usertoollinks-mail' ];
1398        return self::link( $emailPage,
1399            wfMessage( 'emaillink' )->escaped(),
1400            $moreLinkAttribs
1401        );
1402    }
1403
1404    /**
1405     * Generate a user link if the current user is allowed to view it
1406     *
1407     * This method produces HTML that requires CSS styles in mediawiki.interface.helpers.styles.
1408     *
1409     * @since 1.16.3
1410     * @param RevisionRecord $revRecord (Switched from the old Revision class to RevisionRecord
1411     *    since 1.35)
1412     * @param bool $isPublic Show only if all users can see it
1413     * @return string HTML fragment
1414     */
1415    public static function revUserLink( RevisionRecord $revRecord, $isPublic = false ) {
1416        // TODO inject authority
1417        $authority = RequestContext::getMain()->getAuthority();
1418
1419        $revUser = $revRecord->getUser(
1420            $isPublic ? RevisionRecord::FOR_PUBLIC : RevisionRecord::FOR_THIS_USER,
1421            $authority
1422        );
1423        if ( $revUser ) {
1424            $link = self::userLink( $revUser->getId(), $revUser->getName() );
1425        } else {
1426            // User is deleted and we can't (or don't want to) view it
1427            $link = wfMessage( 'rev-deleted-user' )->escaped();
1428        }
1429
1430        if ( $revRecord->isDeleted( RevisionRecord::DELETED_USER ) ) {
1431            $class = self::getRevisionDeletedClass( $revRecord );
1432            return '<span class="' . $class . '">' . $link . '</span>';
1433        }
1434        return $link;
1435    }
1436
1437    /**
1438     * Returns css class of a deleted revision
1439     * @param RevisionRecord $revisionRecord
1440     * @return string 'history-deleted', 'mw-history-suppressed' added if suppressed too
1441     * @since 1.37
1442     */
1443    public static function getRevisionDeletedClass( RevisionRecord $revisionRecord ): string {
1444        $class = 'history-deleted';
1445        if ( $revisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ) {
1446            $class .= ' mw-history-suppressed';
1447        }
1448        return $class;
1449    }
1450
1451    /**
1452     * Generate a user tool link cluster if the current user is allowed to view it
1453     *
1454     * This method produces HTML that requires CSS styles in mediawiki.interface.helpers.styles.
1455     *
1456     * @since 1.16.3
1457     * @param RevisionRecord $revRecord (Switched from the old Revision class to RevisionRecord
1458     *    since 1.35)
1459     * @param bool $isPublic Show only if all users can see it
1460     * @param bool $useParentheses (optional) Wrap comments in parentheses where needed
1461     * @return string HTML
1462     */
1463    public static function revUserTools(
1464        RevisionRecord $revRecord,
1465        $isPublic = false,
1466        $useParentheses = true
1467    ) {
1468        // TODO inject authority
1469        $authority = RequestContext::getMain()->getAuthority();
1470
1471        $revUser = $revRecord->getUser(
1472            $isPublic ? RevisionRecord::FOR_PUBLIC : RevisionRecord::FOR_THIS_USER,
1473            $authority
1474        );
1475        if ( $revUser ) {
1476            $link = self::userLink(
1477                $revUser->getId(),
1478                $revUser->getName(),
1479                false,
1480                [ 'data-mw-revid' => $revRecord->getId() ]
1481            ) . self::userToolLinks(
1482                $revUser->getId(),
1483                $revUser->getName(),
1484                false,
1485                0,
1486                null,
1487                $useParentheses
1488            );
1489        } else {
1490            // User is deleted and we can't (or don't want to) view it
1491            $link = wfMessage( 'rev-deleted-user' )->escaped();
1492        }
1493
1494        if ( $revRecord->isDeleted( RevisionRecord::DELETED_USER ) ) {
1495            $class = self::getRevisionDeletedClass( $revRecord );
1496            return ' <span class="' . $class . ' mw-userlink">' . $link . '</span>';
1497        }
1498        return $link;
1499    }
1500
1501    /**
1502     * Helper function to expand local links. Mostly used in action=render
1503     *
1504     * @since 1.38
1505     * @unstable
1506     *
1507     * @param string $html
1508     *
1509     * @return string HTML
1510     */
1511    public static function expandLocalLinks( string $html ) {
1512        return HtmlHelper::modifyElements(
1513            $html,
1514            static function ( SerializerNode $node ): bool {
1515                return $node->name === 'a' && isset( $node->attrs['href'] );
1516            },
1517            static function ( SerializerNode $node ): SerializerNode {
1518                $urlUtils = MediaWikiServices::getInstance()->getUrlUtils();
1519                $node->attrs['href'] =
1520                    $urlUtils->expand( $node->attrs['href'], PROTO_RELATIVE ) ?? false;
1521                return $node;
1522            }
1523        );
1524    }
1525
1526    /**
1527     * @param LinkTarget|null $contextTitle
1528     * @param string $target
1529     * @param string &$text
1530     * @return string
1531     */
1532    public static function normalizeSubpageLink( $contextTitle, $target, &$text ) {
1533        # Valid link forms:
1534        # Foobar -- normal
1535        # :Foobar -- override special treatment of prefix (images, language links)
1536        # /Foobar -- convert to CurrentPage/Foobar
1537        # /Foobar/ -- convert to CurrentPage/Foobar, strip the initial and final / from text
1538        # ../ -- convert to CurrentPage, from CurrentPage/CurrentSubPage
1539        # ../Foobar -- convert to CurrentPage/Foobar,
1540        #              (from CurrentPage/CurrentSubPage)
1541        # ../Foobar/ -- convert to CurrentPage/Foobar, use 'Foobar' as text
1542        #              (from CurrentPage/CurrentSubPage)
1543
1544        $ret = $target; # default return value is no change
1545
1546        # Some namespaces don't allow subpages,
1547        # so only perform processing if subpages are allowed
1548        if (
1549            $contextTitle && MediaWikiServices::getInstance()->getNamespaceInfo()->
1550            hasSubpages( $contextTitle->getNamespace() )
1551        ) {
1552            $hash = strpos( $target, '#' );
1553            if ( $hash !== false ) {
1554                $suffix = substr( $target, $hash );
1555                $target = substr( $target, 0, $hash );
1556            } else {
1557                $suffix = '';
1558            }
1559            # T9425
1560            $target = trim( $target );
1561            $contextPrefixedText = MediaWikiServices::getInstance()->getTitleFormatter()->
1562                getPrefixedText( $contextTitle );
1563            # Look at the first character
1564            if ( $target != '' && $target[0] === '/' ) {
1565                # / at end means we don't want the slash to be shown
1566                $m = [];
1567                $trailingSlashes = preg_match_all( '%(/+)$%', $target, $m );
1568                if ( $trailingSlashes ) {
1569                    $noslash = $target = substr( $target, 1, -strlen( $m[0][0] ) );
1570                } else {
1571                    $noslash = substr( $target, 1 );
1572                }
1573
1574                $ret = $contextPrefixedText . '/' . trim( $noslash ) . $suffix;
1575                if ( $text === '' ) {
1576                    $text = $target . $suffix;
1577                } # this might be changed for ugliness reasons
1578            } else {
1579                # check for .. subpage backlinks
1580                $dotdotcount = 0;
1581                $nodotdot = $target;
1582                while ( str_starts_with( $nodotdot, '../' ) ) {
1583                    ++$dotdotcount;
1584                    $nodotdot = substr( $nodotdot, 3 );
1585                }
1586                if ( $dotdotcount > 0 ) {
1587                    $exploded = explode( '/', $contextPrefixedText );
1588                    if ( count( $exploded ) > $dotdotcount ) { # not allowed to go below top level page
1589                        $ret = implode( '/', array_slice( $exploded, 0, -$dotdotcount ) );
1590                        # / at the end means don't show full path
1591                        if ( substr( $nodotdot, -1, 1 ) === '/' ) {
1592                            $nodotdot = rtrim( $nodotdot, '/' );
1593                            if ( $text === '' ) {
1594                                $text = $nodotdot . $suffix;
1595                            }
1596                        }
1597                        $nodotdot = trim( $nodotdot );
1598                        if ( $nodotdot != '' ) {
1599                            $ret .= '/' . $nodotdot;
1600                        }
1601                        $ret .= $suffix;
1602                    }
1603                }
1604            }
1605        }
1606
1607        return $ret;
1608    }
1609
1610    /**
1611     * @since 1.16.3
1612     * @param int $size
1613     * @return string
1614     */
1615    public static function formatRevisionSize( $size ) {
1616        if ( $size == 0 ) {
1617            $stxt = wfMessage( 'historyempty' )->escaped();
1618        } else {
1619            $stxt = wfMessage( 'nbytes' )->numParams( $size )->escaped();
1620        }
1621        return "<span class=\"history-size mw-diff-bytes\" data-mw-bytes=\"$size\">$stxt</span>";
1622    }
1623
1624    /**
1625     * Split a link trail, return the "inside" portion and the remainder of the trail
1626     * as a two-element array
1627     * @param string $trail
1628     * @return string[]
1629     */
1630    public static function splitTrail( $trail ) {
1631        $regex = MediaWikiServices::getInstance()->getContentLanguage()->linkTrail();
1632        $inside = '';
1633        if ( $trail !== '' && preg_match( $regex, $trail, $m ) ) {
1634            [ , $inside, $trail ] = $m;
1635        }
1636        return [ $inside, $trail ];
1637    }
1638
1639    /**
1640     * Generate a rollback link for a given revision.  Currently it's the
1641     * caller's responsibility to ensure that the revision is the top one. If
1642     * it's not, of course, the user will get an error message.
1643     *
1644     * If the calling page is called with the parameter &bot=1, all rollback
1645     * links also get that parameter. It causes the edit itself and the rollback
1646     * to be marked as "bot" edits. Bot edits are hidden by default from recent
1647     * changes, so this allows sysops to combat a busy vandal without bothering
1648     * other users.
1649     *
1650     * This function will return the link only in case the revision can be reverted
1651     * (not all revisions are by the same user, and the last revision by a different
1652     * user is visible). Please note that due to performance limitations it might be
1653     * assumed that a user isn't the only contributor of a page while they are, which
1654     * will lead to useless rollback links. Furthermore this won't work if
1655     * $wgShowRollbackEditCount is disabled, so this can only function as an
1656     * additional check.
1657     *
1658     * If the option noBrackets is set the rollback link wont be enclosed in "[]".
1659     *
1660     * @since 1.16.3. $context added in 1.20. $options added in 1.21
1661     *   $rev could be a RevisionRecord since 1.35
1662     *
1663     * @param RevisionRecord $revRecord (Switched from the old Revision class to RevisionRecord
1664     *    since 1.35)
1665     * @param IContextSource|null $context Context to use or null for the main context.
1666     * @param array $options
1667     * @return string
1668     */
1669    public static function generateRollback(
1670        RevisionRecord $revRecord,
1671        ?IContextSource $context = null,
1672        $options = []
1673    ) {
1674        $context ??= RequestContext::getMain();
1675
1676        $editCount = self::getRollbackEditCount( $revRecord );
1677        if ( $editCount === false ) {
1678            return '';
1679        }
1680
1681        $inner = self::buildRollbackLink( $revRecord, $context, $editCount );
1682
1683        $services = MediaWikiServices::getInstance();
1684        // Allow extensions to modify the rollback link.
1685        // Abort further execution if the extension wants full control over the link.
1686        if ( !( new HookRunner( $services->getHookContainer() ) )->onLinkerGenerateRollbackLink(
1687            $revRecord, $context, $options, $inner ) ) {
1688            return $inner;
1689        }
1690
1691        if ( !in_array( 'noBrackets', $options, true ) ) {
1692            $inner = $context->msg( 'brackets' )->rawParams( $inner )->escaped();
1693        }
1694
1695        if ( $services->getUserOptionsLookup()
1696            ->getBoolOption( $context->getUser(), 'showrollbackconfirmation' )
1697        ) {
1698            $services->getStatsFactory()
1699                ->getCounter( 'rollbackconfirmation_event_load_total' )
1700                ->copyToStatsdAt( 'rollbackconfirmation.event.load' )
1701                ->increment();
1702            $context->getOutput()->addModules( 'mediawiki.misc-authed-curate' );
1703        }
1704
1705        return '<span class="mw-rollback-link">' . $inner . '</span>';
1706    }
1707
1708    /**
1709     * This function will return the number of revisions which a rollback
1710     * would revert and will verify that a revision can be reverted (that
1711     * the user isn't the only contributor and the revision we might
1712     * rollback to isn't deleted). These checks can only function as an
1713     * additional check as this function only checks against the last
1714     * $wgShowRollbackEditCount edits.
1715     *
1716     * Returns null if $wgShowRollbackEditCount is disabled or false if
1717     * the user is the only contributor of the page.
1718     *
1719     * @todo Unused outside of this file - should it be made private?
1720     *
1721     * @param RevisionRecord $revRecord (Switched from the old Revision class to RevisionRecord
1722     *    since 1.35)
1723     * @param bool $verify Deprecated since 1.40, has no effect.
1724     * @return int|false|null
1725     */
1726    public static function getRollbackEditCount( RevisionRecord $revRecord, $verify = true ) {
1727        if ( func_num_args() > 1 ) {
1728            wfDeprecated( __METHOD__ . ' with $verify parameter', '1.40' );
1729        }
1730        $showRollbackEditCount = MediaWikiServices::getInstance()->getMainConfig()
1731            ->get( MainConfigNames::ShowRollbackEditCount );
1732
1733        if ( !is_int( $showRollbackEditCount ) || !$showRollbackEditCount > 0 ) {
1734            // Nothing has happened, indicate this by returning 'null'
1735            return null;
1736        }
1737
1738        $dbr = MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
1739
1740        // Up to the value of $wgShowRollbackEditCount revisions are counted
1741        $queryBuilder = MediaWikiServices::getInstance()->getRevisionStore()->newSelectQueryBuilder( $dbr );
1742        $res = $queryBuilder->where( [ 'rev_page' => $revRecord->getPageId() ] )
1743            ->useIndex( [ 'revision' => 'rev_page_timestamp' ] )
1744            ->orderBy( [ 'rev_timestamp', 'rev_id' ], SelectQueryBuilder::SORT_DESC )
1745            ->limit( $showRollbackEditCount + 1 )
1746            ->caller( __METHOD__ )->fetchResultSet();
1747
1748        $revUser = $revRecord->getUser( RevisionRecord::RAW );
1749        $revUserText = $revUser ? $revUser->getName() : '';
1750
1751        $editCount = 0;
1752        $moreRevs = false;
1753        foreach ( $res as $row ) {
1754            if ( $row->rev_user_text != $revUserText ) {
1755                if ( $row->rev_deleted & RevisionRecord::DELETED_TEXT
1756                    || $row->rev_deleted & RevisionRecord::DELETED_USER
1757                ) {
1758                    // If the user or the text of the revision we might rollback
1759                    // to is deleted in some way we can't rollback. Similar to
1760                    // the checks in WikiPage::commitRollback.
1761                    return false;
1762                }
1763                $moreRevs = true;
1764                break;
1765            }
1766            $editCount++;
1767        }
1768
1769        if ( $editCount <= $showRollbackEditCount && !$moreRevs ) {
1770            // We didn't find at least $wgShowRollbackEditCount revisions made by the current user
1771            // and there weren't any other revisions. That means that the current user is the only
1772            // editor, so we can't rollback
1773            return false;
1774        }
1775        return $editCount;
1776    }
1777
1778    /**
1779     * Build a raw rollback link, useful for collections of "tool" links
1780     *
1781     * @since 1.16.3. $context added in 1.20. $editCount added in 1.21
1782     *   $rev could be a RevisionRecord since 1.35
1783     *
1784     * @todo Unused outside of this file - should it be made private?
1785     *
1786     * @param RevisionRecord $revRecord (Switched from the old Revision class to RevisionRecord
1787     *    since 1.35)
1788     * @param IContextSource|null $context Context to use or null for the main context.
1789     * @param int|false|null $editCount Number of edits that would be reverted
1790     * @return string HTML fragment
1791     */
1792    public static function buildRollbackLink(
1793        RevisionRecord $revRecord,
1794        ?IContextSource $context = null,
1795        $editCount = false
1796    ) {
1797        $config = MediaWikiServices::getInstance()->getMainConfig();
1798        $showRollbackEditCount = $config->get( MainConfigNames::ShowRollbackEditCount );
1799        $miserMode = $config->get( MainConfigNames::MiserMode );
1800        // To config which pages are affected by miser mode
1801        $disableRollbackEditCountSpecialPage = [ 'Recentchanges', 'Watchlist' ];
1802
1803        $context ??= RequestContext::getMain();
1804
1805        $title = $revRecord->getPageAsLinkTarget();
1806        $revUser = $revRecord->getUser();
1807        $revUserText = $revUser ? $revUser->getName() : '';
1808
1809        $query = [
1810            'action' => 'rollback',
1811            'from' => $revUserText,
1812            'token' => $context->getUser()->getEditToken( 'rollback' ),
1813        ];
1814
1815        $attrs = [
1816            'data-mw' => 'interface',
1817            'title' => $context->msg( 'tooltip-rollback' )->text()
1818        ];
1819
1820        $options = [ 'known', 'noclasses' ];
1821
1822        if ( $context->getRequest()->getBool( 'bot' ) ) {
1823            // T17999
1824            $query['hidediff'] = '1';
1825            $query['bot'] = '1';
1826        }
1827
1828        if ( $miserMode ) {
1829            foreach ( $disableRollbackEditCountSpecialPage as $specialPage ) {
1830                if ( $context->getTitle()->isSpecial( $specialPage ) ) {
1831                    $showRollbackEditCount = false;
1832                    break;
1833                }
1834            }
1835        }
1836
1837        // The edit count can be 0 on replica lag, fall back to the generic rollbacklink message
1838        $msg = [ 'rollbacklink' ];
1839        if ( is_int( $showRollbackEditCount ) && $showRollbackEditCount > 0 ) {
1840            if ( !is_numeric( $editCount ) ) {
1841                $editCount = self::getRollbackEditCount( $revRecord );
1842            }
1843
1844            if ( $editCount > $showRollbackEditCount ) {
1845                $msg = [ 'rollbacklinkcount-morethan', Message::numParam( $showRollbackEditCount ) ];
1846            } elseif ( $editCount ) {
1847                $msg = [ 'rollbacklinkcount', Message::numParam( $editCount ) ];
1848            }
1849        }
1850
1851        $html = $context->msg( ...$msg )->parse();
1852        return self::link( $title, $html, $attrs, $query, $options );
1853    }
1854
1855    /**
1856     * Returns HTML for the "hidden categories on this page" list.
1857     *
1858     * @since 1.16.3
1859     * @param array $hiddencats Array of hidden categories
1860     *    from {@link WikiPage::getHiddenCategories} or similar
1861     * @return string HTML output
1862     */
1863    public static function formatHiddenCategories( $hiddencats ) {
1864        $outText = '';
1865        if ( count( $hiddencats ) > 0 ) {
1866            # Construct the HTML
1867            $outText = '<div class="mw-hiddenCategoriesExplanation">';
1868            $outText .= wfMessage( 'hiddencategories' )->numParams( count( $hiddencats ) )->parseAsBlock();
1869            $outText .= "</div><ul>\n";
1870
1871            foreach ( $hiddencats as $titleObj ) {
1872                # If it's hidden, it must exist - no need to check with a LinkBatch
1873                $outText .= '<li>'
1874                    . self::link( $titleObj, null, [], [], 'known' )
1875                    . "</li>\n";
1876            }
1877            $outText .= '</ul>';
1878        }
1879        return $outText;
1880    }
1881
1882    /**
1883     * @return ContextSource
1884     */
1885    private static function getContextFromMain() {
1886        $context = RequestContext::getMain();
1887        $context = new DerivativeContext( $context );
1888        return $context;
1889    }
1890
1891    /**
1892     * Given the id of an interface element, constructs the appropriate title
1893     * attribute from the system messages.  (Note, this is usually the id but
1894     * isn't always, because sometimes the accesskey needs to go on a different
1895     * element than the id, for reverse-compatibility, etc.)
1896     *
1897     * @since 1.16.3 $msgParams added in 1.27
1898     * @param string $name Id of the element, minus prefixes.
1899     * @param string|array|null $options Null, string or array with some of the following options:
1900     *   - 'withaccess' to add an access-key hint
1901     *   - 'nonexisting' to add an accessibility hint that page does not exist
1902     * @param array $msgParams Parameters to pass to the message
1903     * @param MessageLocalizer|null $localizer
1904     *
1905     * @return string|false Contents of the title attribute (which you must HTML-
1906     *   escape), or false for no title attribute
1907     */
1908    public static function titleAttrib( $name, $options = null, array $msgParams = [], $localizer = null ) {
1909        if ( !$localizer ) {
1910            $localizer = self::getContextFromMain();
1911        }
1912        $message = $localizer->msg( "tooltip-$name", $msgParams );
1913        // Set a default tooltip for subject namespace tabs if that hasn't
1914        // been defined. See T22126
1915        if ( !$message->exists() && str_starts_with( $name, 'ca-nstab-' ) ) {
1916            $message = $localizer->msg( 'tooltip-ca-nstab' );
1917        }
1918
1919        if ( $message->isDisabled() ) {
1920            $tooltip = false;
1921        } else {
1922            $tooltip = $message->text();
1923            # Compatibility: formerly some tooltips had [alt-.] hardcoded
1924            $tooltip = preg_replace( "/ ?\[alt-.\]$/", '', $tooltip );
1925        }
1926
1927        $options = (array)$options;
1928
1929        if ( in_array( 'nonexisting', $options ) ) {
1930            $tooltip = $localizer->msg( 'red-link-title', $tooltip ?: '' )->text();
1931        }
1932        if ( in_array( 'withaccess', $options ) ) {
1933            $accesskey = self::accesskey( $name, $localizer );
1934            if ( $accesskey !== false ) {
1935                // Should be build the same as in jquery.accessKeyLabel.js
1936                if ( $tooltip === false || $tooltip === '' ) {
1937                    $tooltip = $localizer->msg( 'brackets', $accesskey )->text();
1938                } else {
1939                    $tooltip .= $localizer->msg( 'word-separator' )->text();
1940                    $tooltip .= $localizer->msg( 'brackets', $accesskey )->text();
1941                }
1942            }
1943        }
1944
1945        return $tooltip;
1946    }
1947
1948    /** @var (string|false)[] */
1949    public static $accesskeycache;
1950
1951    /**
1952     * Given the id of an interface element, constructs the appropriate
1953     * accesskey attribute from the system messages.  (Note, this is usually
1954     * the id but isn't always, because sometimes the accesskey needs to go on
1955     * a different element than the id, for reverse-compatibility, etc.)
1956     *
1957     * @since 1.16.3
1958     * @param string $name Id of the element, minus prefixes.
1959     * @param MessageLocalizer|null $localizer
1960     * @return string|false Contents of the accesskey attribute (which you must HTML-
1961     *   escape), or false for no accesskey attribute
1962     */
1963    public static function accesskey( $name, $localizer = null ) {
1964        if ( !isset( self::$accesskeycache[$name] ) ) {
1965            if ( !$localizer ) {
1966                $localizer = self::getContextFromMain();
1967            }
1968            $msg = $localizer->msg( "accesskey-$name" );
1969            // Set a default accesskey for subject namespace tabs if an
1970            // accesskey has not been defined. See T22126
1971            if ( !$msg->exists() && str_starts_with( $name, 'ca-nstab-' ) ) {
1972                $msg = $localizer->msg( 'accesskey-ca-nstab' );
1973            }
1974            self::$accesskeycache[$name] = $msg->isDisabled() ? false : $msg->plain();
1975        }
1976        return self::$accesskeycache[$name];
1977    }
1978
1979    /**
1980     * Get a revision-deletion link, or disabled link, or nothing, depending
1981     * on user permissions & the settings on the revision.
1982     *
1983     * Will use forward-compatible revision ID in the Special:RevDelete link
1984     * if possible, otherwise the timestamp-based ID which may break after
1985     * undeletion.
1986     *
1987     * @param Authority $performer
1988     * @param RevisionRecord $revRecord (Switched from the old Revision class to RevisionRecord
1989     *    since 1.35)
1990     * @param LinkTarget $title
1991     * @return string HTML fragment
1992     */
1993    public static function getRevDeleteLink(
1994        Authority $performer,
1995        RevisionRecord $revRecord,
1996        LinkTarget $title
1997    ) {
1998        $canHide = $performer->isAllowed( 'deleterevision' );
1999        $canHideHistory = $performer->isAllowed( 'deletedhistory' );
2000        if ( !$canHide && !( $revRecord->getVisibility() && $canHideHistory ) ) {
2001            return '';
2002        }
2003
2004        if ( !$revRecord->userCan( RevisionRecord::DELETED_RESTRICTED, $performer ) ) {
2005            return self::revDeleteLinkDisabled( $canHide ); // revision was hidden from sysops
2006        }
2007        $prefixedDbKey = MediaWikiServices::getInstance()->getTitleFormatter()->
2008            getPrefixedDBkey( $title );
2009        if ( $revRecord->getId() ) {
2010            // RevDelete links using revision ID are stable across
2011            // page deletion and undeletion; use when possible.
2012            $query = [
2013                'type' => 'revision',
2014                'target' => $prefixedDbKey,
2015                'ids' => $revRecord->getId()
2016            ];
2017        } else {
2018            // Older deleted entries didn't save a revision ID.
2019            // We have to refer to these by timestamp, ick!
2020            $query = [
2021                'type' => 'archive',
2022                'target' => $prefixedDbKey,
2023                'ids' => $revRecord->getTimestamp()
2024            ];
2025        }
2026        return self::revDeleteLink(
2027            $query,
2028            $revRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ),
2029            $canHide
2030        );
2031    }
2032
2033    /**
2034     * Creates a (show/hide) link for deleting revisions/log entries
2035     *
2036     * This method produces HTML that requires CSS styles in mediawiki.interface.helpers.styles.
2037     *
2038     * @param array $query Query parameters to be passed to link()
2039     * @param bool $restricted Set to true to use a "<strong>" instead of a "<span>"
2040     * @param bool $delete Set to true to use (show/hide) rather than (show)
2041     *
2042     * @return string HTML "<a>" link to Special:Revisiondelete, wrapped in a
2043     * span to allow for customization of appearance with CSS
2044     */
2045    public static function revDeleteLink( $query = [], $restricted = false, $delete = true ) {
2046        $sp = SpecialPage::getTitleFor( 'Revisiondelete' );
2047        $msgKey = $delete ? 'rev-delundel' : 'rev-showdeleted';
2048        $html = wfMessage( $msgKey )->escaped();
2049        $tag = $restricted ? 'strong' : 'span';
2050        $link = self::link( $sp, $html, [], $query, [ 'known', 'noclasses' ] );
2051        return Xml::tags(
2052            $tag,
2053            [ 'class' => 'mw-revdelundel-link' ],
2054            wfMessage( 'parentheses' )->rawParams( $link )->escaped()
2055        );
2056    }
2057
2058    /**
2059     * Creates a dead (show/hide) link for deleting revisions/log entries
2060     *
2061     * This method produces HTML that requires CSS styles in mediawiki.interface.helpers.styles.
2062     *
2063     * @since 1.16.3
2064     * @param bool $delete Set to true to use (show/hide) rather than (show)
2065     *
2066     * @return string HTML text wrapped in a span to allow for customization
2067     * of appearance with CSS
2068     */
2069    public static function revDeleteLinkDisabled( $delete = true ) {
2070        $msgKey = $delete ? 'rev-delundel' : 'rev-showdeleted';
2071        $html = wfMessage( $msgKey )->escaped();
2072        $htmlParentheses = wfMessage( 'parentheses' )->rawParams( $html )->escaped();
2073        return Xml::tags( 'span', [ 'class' => 'mw-revdelundel-link' ], $htmlParentheses );
2074    }
2075
2076    /**
2077     * Returns the attributes for the tooltip and access key.
2078     *
2079     * @since 1.16.3. $msgParams introduced in 1.27
2080     * @param string $name
2081     * @param array $msgParams Params for constructing the message
2082     * @param string|array|null $options Options to be passed to titleAttrib.
2083     * @param MessageLocalizer|null $localizer
2084     *
2085     * @see Linker::titleAttrib for what options could be passed to $options.
2086     *
2087     * @return array
2088     */
2089    public static function tooltipAndAccesskeyAttribs(
2090        $name,
2091        array $msgParams = [],
2092        $options = null,
2093        $localizer = null
2094    ) {
2095        $options = (array)$options;
2096        $options[] = 'withaccess';
2097
2098        // Get optional parameters from global context if any missing.
2099        if ( !$localizer ) {
2100            $localizer = self::getContextFromMain();
2101        }
2102
2103        $attribs = [
2104            'title' => self::titleAttrib( $name, $options, $msgParams, $localizer ),
2105            'accesskey' => self::accesskey( $name, $localizer )
2106        ];
2107        if ( $attribs['title'] === false ) {
2108            unset( $attribs['title'] );
2109        }
2110        if ( $attribs['accesskey'] === false ) {
2111            unset( $attribs['accesskey'] );
2112        }
2113        return $attribs;
2114    }
2115
2116    /**
2117     * Returns raw bits of HTML, use titleAttrib()
2118     * @since 1.16.3
2119     * @param string $name
2120     * @param array|null $options
2121     * @return null|string
2122     */
2123    public static function tooltip( $name, $options = null ) {
2124        $tooltip = self::titleAttrib( $name, $options );
2125        if ( $tooltip === false ) {
2126            return '';
2127        }
2128        return Xml::expandAttributes( [
2129            'title' => $tooltip
2130        ] );
2131    }
2132
2133}