Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 204
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ExternalLinkHandler
0.00% covered (danger)
0.00%
0 / 204
0.00% covered (danger)
0.00%
0 / 9
2862
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 imageExtensions
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 arraySome
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 hasImageLink
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 onUrlLink
0.00% covered (danger)
0.00%
0 / 50
0.00% covered (danger)
0.00%
0 / 1
90
 onExtLink
0.00% covered (danger)
0.00%
0 / 83
0.00% covered (danger)
0.00%
0 / 1
462
 getTemplateInfo
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
110
 wrapReturn
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
2
 onTag
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2declare( strict_types = 1 );
3
4namespace Wikimedia\Parsoid\Wt2Html\TT;
5
6use Wikimedia\Parsoid\Core\Sanitizer;
7use Wikimedia\Parsoid\DOM\Element;
8use Wikimedia\Parsoid\DOM\Node;
9use Wikimedia\Parsoid\Html2Wt\WTSUtils;
10use Wikimedia\Parsoid\NodeData\DataParsoid;
11use Wikimedia\Parsoid\NodeData\TemplateInfo;
12use Wikimedia\Parsoid\Tokens\EndTagTk;
13use Wikimedia\Parsoid\Tokens\KV;
14use Wikimedia\Parsoid\Tokens\SelfclosingTagTk;
15use Wikimedia\Parsoid\Tokens\TagTk;
16use Wikimedia\Parsoid\Tokens\Token;
17use Wikimedia\Parsoid\Tokens\XMLTagTk;
18use Wikimedia\Parsoid\Utils\DOMDataUtils;
19use Wikimedia\Parsoid\Utils\DOMUtils;
20use Wikimedia\Parsoid\Utils\PHPUtils;
21use Wikimedia\Parsoid\Utils\PipelineUtils;
22use Wikimedia\Parsoid\Utils\TokenUtils;
23use Wikimedia\Parsoid\Utils\WTUtils;
24use Wikimedia\Parsoid\Wt2Html\PegTokenizer;
25
26class ExternalLinkHandler extends XMLTagBasedHandler {
27    /** @var PegTokenizer */
28    private $urlParser;
29
30    /** @inheritDoc */
31    public function __construct( object $manager, array $options ) {
32        parent::__construct( $manager, $options );
33
34        // Create a new peg parser for image options.
35        if ( !$this->urlParser ) {
36            // Actually the regular tokenizer, but we'll call it with the
37            // url rule only.
38            $this->urlParser = new PegTokenizer( $this->env );
39        }
40    }
41
42    private static function imageExtensions( string $str ): bool {
43        return match ( $str ) {
44            'avif', 'gif', 'jpeg', 'jpg', 'png', 'svg', 'webp' => true,
45            default => false
46        };
47    }
48
49    private function arraySome( array $array, callable $fn ): bool {
50        foreach ( $array as $value ) {
51            if ( $fn( $value ) ) {
52                return true;
53            }
54        }
55        return false;
56    }
57
58    private function hasImageLink( string $href ): bool {
59        $allowedPrefixes = $this->env->getSiteConfig()->allowedExternalImagePrefixes();
60        $bits = explode( '.', $href );
61        $hasImageExtension = count( $bits ) > 1 &&
62            self::imageExtensions( end( $bits ) ) &&
63            preg_match( '#^https?://#i', $href );
64        // Typical settings for mediawiki configuration variables
65        // $wgAllowExternalImages and $wgAllowExternalImagesFrom will
66        // result in values like these:
67        //  allowedPrefixes = undefined; // no external images
68        //  allowedPrefixes = [''];      // allow all external images
69        //  allowedPrefixes = ['http://127.0.0.1/', 'http://example.com'];
70        // Note that the values include the http:// or https:// protocol.
71        // See https://phabricator.wikimedia.org/T53092
72        return $hasImageExtension &&
73            // true if some prefix in the list matches href
74            self::arraySome( $allowedPrefixes, static function ( string $prefix ) use ( &$href ) {
75                return $prefix === "" || str_starts_with( $href, $prefix );
76            } );
77    }
78
79    /**
80     * @return ?array<string|Token>
81     */
82    private function onUrlLink( Token $token ): ?array {
83        $env = $this->env;
84        $origHref = $token->getAttributeV( 'href' );
85        $dataParsoid = clone $token->dataParsoid;
86        $dataMw = $token->dataMw ? clone $token->dataMw : null;
87
88        $hrefTokens = TokenUtils::tokensToString( $origHref, true, [
89            'includeEntities' => true,
90            'includeUrlLink' => true,
91        ] );
92
93        // We assume that, if $hrefTokens is an array, then some part of
94        // it is templated.  However, in some cases (like the content of
95        // templated extensions), we may be expanding templates but not
96        // wrappping them, in which case we won't find tplarginfo
97        $wrapTemplates = !$this->options['inTemplate'];
98        $tplarginfo = null;
99        if ( is_array( $hrefTokens ) ) {
100            if ( $wrapTemplates ) {
101                $tplarginfo = $this->getTemplateInfo( $token );
102            }
103            $max = count( $origHref ) - count( $hrefTokens[1] );
104            // Conservatively, only include the rest of the tokens as content
105            // if we aren't wrapping or if we find template info
106            $content = ( !$wrapTemplates || $tplarginfo ) ? $hrefTokens[1] : [];
107        } else {
108            $max = null;
109            $content = [];
110        }
111        $href = TokenUtils::tokensToString( $origHref, false, [], $max );
112
113        if ( $this->hasImageLink( $href ) ) {
114            $checkAlt = explode( '/', $href );
115            $tagAttrs = [
116                new KV( 'src', $href ),
117                new KV( 'alt', end( $checkAlt ) ),
118                new KV( 'rel', 'mw:externalImage' )
119            ];
120
121            // combine with existing rdfa attrs
122            $tagAttrs = WikiLinkHandler::buildLinkAttrs(
123                $token->attribs, false, null, $tagAttrs )['attribs'];
124            return [ new SelfclosingTagTk( 'img', $tagAttrs, $dataParsoid, $dataMw ) ];
125        } else {
126            $tagAttrs = [
127                new KV( 'rel', 'mw:ExtLink' )
128            ];
129
130            // combine with existing rdfa attrs
131            // href is set explicitly below
132            $tagAttrs = WikiLinkHandler::buildLinkAttrs(
133                $token->attribs, false, null, $tagAttrs )['attribs'];
134            $builtTag = new TagTk( 'a', $tagAttrs, $dataParsoid, $dataMw );
135            $dataParsoid->stx = 'url';
136
137            if ( !$this->options['inTemplate'] ) {
138                // Since we messed with the text of the link, we need
139                // to preserve the original in the RT data. Or else.
140                $builtTag->addNormalizedAttribute(
141                    'href', $href, $token->getWTSource( $this->manager->getFrame()->getSource() )
142                );
143            } else {
144                $builtTag->addAttribute( 'href', $href );
145            }
146
147            $dp = new DataParsoid;
148            $dp->tsr = $dataParsoid->tsr->expandTsrK()->value;
149
150            $ret = array_merge( [
151                $builtTag,
152                // Make sure there are no IDN-ignored characters in the text so
153                // the user doesn't accidentally copy any.
154                Sanitizer::cleanUrl( $env->getSiteConfig(), $href, 'wikilink' ), // mode could be 'wikilink'
155                new EndTagTk( 'a', [], $dp )
156            ], $content );
157
158            if ( $tplarginfo !== null ) {
159                $this->wrapReturn( $token, $tplarginfo, $ret );
160            }
161            return $ret;
162        }
163    }
164
165    /**
166     * Bracketed external link
167     * @param Token $token
168     * @return ?array<string|Token>
169     */
170    private function onExtLink( Token $token ): ?array {
171        $env = $this->env;
172        $origHref = $token->getAttributeV( 'href' );
173        $hasExpandedAttrs = TokenUtils::hasTypeOf( $token, 'mw:ExpandedAttrs' );
174        $dataParsoid = clone $token->dataParsoid;
175        $dataMw = $token->dataMw ? clone $token->dataMw : null;
176        $magLinkType = TokenUtils::matchTypeOf(
177            $token, '#^mw:(Ext|Wiki)Link/(ISBN|RFC|PMID)$#'
178        );
179
180        $content = $token->getAttributeV( 'mw:content' );
181        if ( !is_array( $content ) ) {
182            $content = [ $content ];
183        }
184
185        $hrefTokens = TokenUtils::tokensToString( $origHref, true, [
186            'includeEntities' => true,
187            'includeUrlLink' => true,
188        ] );
189        if ( is_array( $hrefTokens ) ) {
190            $max = count( $origHref ) - count( $hrefTokens[1] );
191            $hrefWithEntities = $hrefTokens[0];
192        } else {
193            $max = null;
194            $hrefWithEntities = $hrefTokens;
195        }
196        $href = TokenUtils::tokensToString( $origHref, false, [], $max );
197
198        if ( $magLinkType ) {
199            $newHref = $href;
200            $newRel = 'mw:ExtLink';
201            if ( str_ends_with( $magLinkType, '/ISBN' ) ) {
202                $newHref = $env->getSiteConfig()->relativeLinkPrefix() . $href;
203                // ISBNs use mw:WikiLink instead of mw:ExtLink
204                $newRel = 'mw:WikiLink';
205            }
206            $newAttrs = [
207                new KV( 'href', $newHref ),
208                new KV( 'rel', $newRel )
209            ];
210            $token->removeAttribute( 'typeof' );
211
212            // SSS FIXME: Right now, Parsoid does not support templating
213            // of ISBN attributes.  So, "ISBN {{1x|1234567890}}" will not
214            // parse as you might expect it to.  As a result, this code below
215            // that attempts to combine rdf attrs from earlier is unnecessary
216            // right now.  But, it will become necessary if Parsoid starts
217            // supporting templating of ISBN attributes.
218            //
219            // combine with existing rdfa attrs
220            $newAttrs = WikiLinkHandler::buildLinkAttrs(
221                $token->attribs, false, null, $newAttrs )['attribs'];
222            $aStart = new TagTk( 'a', $newAttrs, $dataParsoid, $dataMw );
223            $tokens = array_merge( [ $aStart ], $content, [ new EndTagTk( 'a' ) ] );
224            return $tokens;
225        } elseif (
226            ( !$hasExpandedAttrs && is_string( $origHref ) ) ||
227            $this->urlParser->tokenizeURL( $hrefWithEntities ) !== false
228        ) {
229            // We assume that, if $hrefTokens is an array, then some part of
230            // it is templated.  However, in some cases (like the content of
231            // templated extensions), we may be expanding templates but not
232            // wrappping them, in which case we won't find tplarginfo
233            $wrapTemplates = !$this->options['inTemplate'];
234            $tplarginfo = null;
235            if ( is_array( $hrefTokens ) ) {
236                if ( $wrapTemplates ) {
237                    $tplarginfo = $this->getTemplateInfo( $token );
238                }
239                // Conservatively, only include the rest of the tokens as content
240                // if we aren't wrapping or if we find template info
241                if ( !$wrapTemplates || $tplarginfo ) {
242                    $content = array_merge( $hrefTokens[1], $content );
243                }
244            }
245
246            if ( count( $content ) === 1 && is_string( $content[0] ) ) {
247                $src = $content[0];
248                if ( $env->getSiteConfig()->hasValidProtocol( $src ) &&
249                    $this->urlParser->tokenizeURL( $src ) !== false &&
250                    $this->hasImageLink( $src )
251                ) {
252                    $checkAlt = explode( '/', $src );
253                    $dp = new DataParsoid;
254                    $dp->type = 'extlink';
255                    $content = [ new SelfclosingTagTk( 'img', [
256                        new KV( 'src', $src ),
257                        new KV( 'alt', end( $checkAlt ) )
258                        ], $dp
259                    ) ];
260                }
261            }
262
263            $newAttrs = [ new KV( 'rel', 'mw:ExtLink' ) ];
264            // combine with existing rdfa attrs
265            // href is set explicitly below
266            $newAttrs = WikiLinkHandler::buildLinkAttrs(
267                $token->attribs, false, null, $newAttrs
268            )['attribs'];
269            $aStart = new TagTk( 'a', $newAttrs, $dataParsoid, $dataMw );
270
271            if ( !$this->options['inTemplate'] ) {
272                // If we are from a top-level page, add normalized attr info for
273                // accurate roundtripping of original content.
274                //
275                // extLinkContentOffsets->start covers all spaces before content
276                // and we need src without those spaces.
277                $tsr0a = $dataParsoid->tsr->start + 1;
278                $tsr1a = $dataParsoid->tmp->extLinkContentOffsets->start -
279                    strlen( $token->getAttributeV( 'spaces' ) ?? '' );
280                $length = $tsr1a - $tsr0a;
281                $source = $dataParsoid->tsr->source ?? $this->manager->getFrame()->getSource();
282                $aStart->addNormalizedAttribute( 'href', $href,
283                    PHPUtils::safeSubstr( $source->getSrcText(), $tsr0a, $length ) );
284            } else {
285                $aStart->addAttribute( 'href', $href );
286            }
287
288            $content = PipelineUtils::getDOMFragmentToken(
289                $content,
290                $dataParsoid->tsr ? $dataParsoid->tmp->extLinkContentOffsets : null,
291                [ 'inlineContext' => true, 'token' => $token ]
292            );
293
294            $ret = [ $aStart, $content, new EndTagTk( 'a' ) ];
295            if ( $tplarginfo !== null ) {
296                $this->wrapReturn( $token, $tplarginfo, $ret );
297            }
298            return $ret;
299        } else {
300            // Not a link, convert href to plain text.
301            return WikiLinkHandler::bailTokens( $this->manager, $token );
302        }
303    }
304
305    private function getTemplateInfo( Token $token ): ?TemplateInfo {
306        $df = WTSUtils::getAttrFromDataMw(
307            $token->dataMw, 'href', true
308        )->value['html'] ?? null;
309        if ( $df == null ) {
310            return null;
311        }
312
313        $tpl = null;
314        DOMUtils::visitDOM( $df, static function ( Node $node ) use ( &$tpl ) {
315            if (
316                $tpl == null && $node instanceof Element &&
317                WTUtils::matchTplType( $node )
318            ) {
319                $tpl = $node;
320            }
321        } );
322        if ( $tpl === null ) {
323            return null;
324        }
325
326        $tplarginfo = null;
327        foreach ( ( DOMDataUtils::getDataMw( $tpl )->parts ?? [] ) as $part ) {
328            if ( $part instanceof TemplateInfo ) {
329                $tplarginfo = clone $part;
330                $tplDsr = DOMDataUtils::getDataParsoid( $tpl )->dsr;
331                $tkTsr = $token->dataParsoid->tsr;
332                $str = $tkTsr->source->getSrcText();
333                if ( $tplDsr->start > $tkTsr->start ) {
334                    $tplarginfo->prePart = PHPUtils::safeSubstr(
335                        $str, $tkTsr->start, $tplDsr->start - $tkTsr->start
336                    );
337                }
338                if ( $tkTsr->end > $tplDsr->end ) {
339                    $tplarginfo->postPart = PHPUtils::safeSubstr(
340                        $str, $tplDsr->end, $tkTsr->end - $tplDsr->end
341                    );
342                }
343                break;
344            }
345        }
346        return $tplarginfo;
347    }
348
349    private function wrapReturn( Token $token, TemplateInfo $tplarginfo, array &$ret ) {
350        $dp = new DataParsoid;
351        $dp->tsr = clone $token->dataParsoid->tsr;
352        $dp->getTemp()->tplarginfo = $tplarginfo;
353
354        $aboutId = $this->env->newAboutId();
355        $attrs = [
356            new KV( 'typeof', 'mw:Transclusion' ),
357            new KV( 'about', $aboutId )
358        ];
359        $startMeta = new SelfclosingTagTk( 'meta', $attrs, $dp );
360        array_unshift( $ret, $startMeta );
361        $attrs = [
362            new KV( 'typeof', 'mw:Transclusion/End' ),
363            new KV( 'about', $aboutId )
364        ];
365        $endMeta = new SelfclosingTagTk( 'meta', $attrs, $dp );
366        array_push( $ret, $endMeta );
367    }
368
369    /** @inheritDoc */
370    public function onTag( XMLTagTk $token ): ?array {
371        return match ( $token->getName() ) {
372            'urllink' => $this->onUrlLink( $token ),
373            'extlink' => $this->onExtLink( $token ),
374            default => null
375        };
376    }
377}