Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
62.07% covered (warning)
62.07%
144 / 232
33.33% covered (danger)
33.33%
7 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
UrlShortenerUtils
62.07% covered (warning)
62.07%
144 / 232
33.33% covered (danger)
33.33%
7 / 21
372.85
0.00% covered (danger)
0.00%
0 / 1
 maybeCreateShortCode
18.18% covered (danger)
18.18%
8 / 44
0.00% covered (danger)
0.00%
0 / 1
43.05
 normalizeUrl
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
7
 convertToProtocol
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getURL
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
3.04
 isURLDeleted
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 deleteURL
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 restoreURL
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 cartesianProduct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 getShortcodeVariants
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 purgeCdnId
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 purgeCdn
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getPrimaryDB
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getReplicaDB
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 makeUrl
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getAllowedDomainsRegex
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 validateUrl
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
90
 encodeId
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 decodeId
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
9.05
 shouldShortenUrl
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getQrCode
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
5.00
 getQrCodeInternal
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * Functions used for decoding/encoding URLs
4 *
5 * @file
6 * @ingroup Extensions
7 * @author Yuvi Panda, http://yuvi.in
8 * @copyright © 2014 Yuvaraj Pandian (yuvipanda@gmail.com)
9 * @license Apache-2.0
10 */
11
12namespace MediaWiki\Extension\UrlShortener;
13
14use Endroid\QrCode\Builder\Builder;
15use Endroid\QrCode\Encoding\Encoding;
16use Endroid\QrCode\Writer\Result\ResultInterface;
17use Endroid\QrCode\Writer\SvgWriter;
18use MediaWiki\Deferred\CdnCacheUpdate;
19use MediaWiki\Deferred\DeferredUpdates;
20use MediaWiki\MediaWikiServices;
21use MediaWiki\SpecialPage\SpecialPage;
22use MediaWiki\Status\Status;
23use MediaWiki\User\User;
24use Message;
25use Wikimedia\Rdbms\IDatabase;
26use Wikimedia\Rdbms\IReadableDatabase;
27
28class UrlShortenerUtils {
29
30    /**
31     * How long to cache valid redirects in CDN (one month)
32     *
33     * @var int
34     */
35    public const CACHE_TTL_VALID = 2592000;
36
37    /**
38     * How long to cache invalid redirects in CDN (fifteen minutes)
39     *
40     * @var int
41     */
42    public const CACHE_TTL_INVALID = 900;
43
44    /** @var int[] */
45    public static $decodeMap;
46
47    /**
48     * Gets the short code for the given URL, creating if it doesn't
49     * have one already.
50     *
51     * If it already exists in cache or the database, just returns that.
52     * Otherwise, a new shortcode entry is created and returned.
53     *
54     * @param string $url URL to encode
55     * @param User $user User requesting the url, for rate limiting
56     * @return Status Status with value of base36 encoded shortcode that refers to the $url
57     */
58    public static function maybeCreateShortCode( string $url, User $user ): Status {
59        global $wgUrlShortenerUrlSizeLimit;
60        $url = self::normalizeUrl( $url );
61
62        if ( $user->getBlock() ) {
63            return Status::newFatal( 'urlshortener-blocked' );
64        }
65
66        global $wgUrlShortenerReadOnly;
67        if ( $wgUrlShortenerReadOnly ) {
68            // All code paths should already have checked for this,
69            // but lets be on the safe side.
70            return Status::newFatal( 'urlshortener-disabled' );
71        }
72
73        if ( mb_strlen( $url ) > $wgUrlShortenerUrlSizeLimit ) {
74            return Status::newFatal(
75                wfMessage( 'urlshortener-url-too-long' )->numParams( $wgUrlShortenerUrlSizeLimit )
76            );
77        }
78
79        if ( $user->pingLimiter( 'urlshortcode' ) ) {
80            return Status::newFatal( 'urlshortener-ratelimit' );
81        }
82
83        $dbr = self::getReplicaDB();
84        $row = $dbr->newSelectQueryBuilder()
85            ->select( [ 'usc_id', 'usc_deleted' ] )
86            ->from( 'urlshortcodes' )
87            ->where( [ 'usc_url_hash' => md5( $url ) ] )
88            ->caller( __METHOD__ )->fetchRow();
89        if ( $row !== false ) {
90            if ( $row->usc_deleted ) {
91                return Status::newFatal( 'urlshortener-deleted' );
92            }
93            return Status::newGood( [
94                'url' => self::encodeId( $row->usc_id ),
95                'alt' => self::encodeId( $row->usc_id, true )
96            ] );
97        }
98
99        $dbw = self::getPrimaryDB();
100        $dbw->newInsertQueryBuilder()
101            ->insertInto( 'urlshortcodes' )
102            ->ignore()
103            ->row( [ 'usc_url' => $url, 'usc_url_hash' => md5( $url ) ] )
104            ->caller( __METHOD__ )->execute();
105
106        if ( $dbw->affectedRows() ) {
107            $id = $dbw->insertId();
108        } else {
109            // Raced out; get the winning ID
110            $id = $dbw->newSelectQueryBuilder()
111                ->select( 'usc_id' )
112                // ignore snapshot
113                ->lockInShareMode()
114                ->from( 'urlshortcodes' )
115                ->where( [ 'usc_url_hash' => md5( $url ) ] )
116                ->caller( __METHOD__ )->fetchField();
117        }
118
119        // In case our CDN cached an earlier 404/error, purge it
120        self::purgeCdnId( $id );
121
122        return Status::newGood( [
123            'url' => self::encodeId( $id ),
124            'alt' => self::encodeId( $id, true )
125        ] );
126    }
127
128    /**
129     * Normalizes URL into a somewhat canonical form, including:
130     * * protocol to HTTP
131     * * from its `/w/index.php?title=$1` form to `/wiki/$1`.
132     *
133     * @param string $url might be encoded or decoded (raw user input)
134     * @return string URL that is saved in DB and used in Location header
135     */
136    public static function normalizeUrl( string $url ): string {
137        global $wgArticlePath;
138        // First, force the protocol to HTTP, we'll convert
139        // it to a different one when redirecting
140        $url = self::convertToProtocol( $url, PROTO_HTTP );
141
142        $url = trim( $url );
143
144        // TODO: We should ideally decode/encode the URL for normalization,
145        // but we don't want to double-encode, nor unencode the URL that
146        // is directly provided by users (see test cases)
147        // So for now, just replace spaces with %20, as that's safe in all cases
148        $url = str_replace( ' ', '%20', $url );
149
150        // If the wiki is using an article path (e.g. /wiki/$1) try
151        // and convert plain index.php?title=$1 URLs to the canonical form
152        $parsed = wfParseUrl( $url );
153        if ( !isset( $parsed['path'] ) ) {
154            // T220718: Ensure each URL has a / after the domain name
155            $parsed['path'] = '/';
156        }
157        if ( $wgArticlePath !== false && isset( $parsed['query'] ) ) {
158            $query = wfCgiToArray( $parsed['query'] );
159            if ( count( $query ) === 1 && isset( $query['title'] ) && $parsed['path'] === wfScript() ) {
160                $parsed['path'] = str_replace( '$1', $query['title'], $wgArticlePath );
161                unset( $parsed['query'] );
162            }
163        }
164        $url = wfAssembleUrl( $parsed );
165
166        return $url;
167    }
168
169    /**
170     * Converts a possibly protocol'd url to the one specified
171     *
172     * @param string $url
173     * @param string|int $proto PROTO_* constant
174     * @return string
175     */
176    public static function convertToProtocol( string $url, $proto = PROTO_RELATIVE ): string {
177        $parsed = wfParseUrl( $url );
178        unset( $parsed['scheme'] );
179        $parsed['delimiter'] = '//';
180
181        return wfExpandUrl( wfAssembleUrl( $parsed ), $proto );
182    }
183
184    /**
185     * Retrieves a URL for the given shortcode, or false if there's none.
186     *
187     * @param string $shortCode
188     * @param string|int|null $proto PROTO_* constant
189     * @return string|false
190     */
191    public static function getURL( string $shortCode, $proto = PROTO_RELATIVE ) {
192        $id = self::decodeId( $shortCode );
193        if ( $id === false ) {
194            return false;
195        }
196
197        $dbr = self::getReplicaDB();
198        $url = $dbr->newSelectQueryBuilder()
199            ->select( 'usc_url' )
200            ->from( 'urlshortcodes' )
201            ->where( [ 'usc_id' => $id, 'usc_deleted' => 0 ] )
202            ->caller( __METHOD__ )->fetchField();
203
204        if ( $url === false ) {
205            return false;
206        }
207
208        return self::convertToProtocol( $url, $proto );
209    }
210
211    /**
212     * Whether a URL is deleted or not
213     *
214     * @param string $shortCode
215     * @return bool
216     */
217    public static function isURLDeleted( string $shortCode ): bool {
218        $id = self::decodeId( $shortCode );
219        if ( $id === false ) {
220            return false;
221        }
222
223        $dbr = self::getReplicaDB();
224        $url = $dbr->newSelectQueryBuilder()
225            ->select( 'usc_url' )
226            ->from( 'urlshortcodes' )
227            ->where( [ 'usc_id' => $id, 'usc_deleted' => 1 ] )
228            ->caller( __METHOD__ )->fetchField();
229
230        return $url !== false;
231    }
232
233    /**
234     * Mark a URL as deleted
235     *
236     * @param string $shortcode
237     * @return bool False if the $shortCode was invalid
238     */
239    public static function deleteURL( string $shortcode ): bool {
240        $id = self::decodeId( $shortcode );
241        if ( $id === false ) {
242            return false;
243        }
244
245        $dbw = self::getPrimaryDB();
246        $dbw->newUpdateQueryBuilder()
247            ->update( 'urlshortcodes' )
248            ->set( [ 'usc_deleted' => 1 ] )
249            ->where( [ 'usc_id' => $id ] )
250            ->caller( __METHOD__ )->execute();
251
252        self::purgeCdnId( $id );
253
254        return true;
255    }
256
257    /**
258     * Mark a URL as undeleted
259     *
260     * @param string $shortcode
261     * @return bool False if the $shortCode was invalid
262     */
263    public static function restoreURL( string $shortcode ): bool {
264        $id = self::decodeId( $shortcode );
265        if ( $id === false ) {
266            return false;
267        }
268
269        $dbw = self::getPrimaryDB();
270        $dbw->newUpdateQueryBuilder()
271            ->update( 'urlshortcodes' )
272            ->set( [ 'usc_deleted' => 0 ] )
273            ->where( [ 'usc_id' => $id ] )
274            ->caller( __METHOD__ )->execute();
275
276        self::purgeCdnId( $id );
277
278        return true;
279    }
280
281    /**
282     * Compute the Cartesian product of a list of sets
283     *
284     * @param array[] $sets List of sets
285     * @return array[]
286     */
287    public static function cartesianProduct( array $sets ): array {
288        if ( !$sets ) {
289            return [ [] ];
290        }
291
292        $set = array_shift( $sets );
293        $productSet = self::cartesianProduct( $sets );
294
295        $result = [];
296        foreach ( $set as $val ) {
297            foreach ( $productSet as $p ) {
298                array_unshift( $p, $val );
299                $result[] = $p;
300            }
301        }
302
303        return $result;
304    }
305
306    /**
307     * Compute all shortcode variants by expanding wgUrlShortenerIdMapping
308     *
309     * @param string $shortcode
310     * @return string[]
311     */
312    public static function getShortcodeVariants( string $shortcode ): array {
313        global $wgUrlShortenerIdMapping;
314
315        // Reverse the character alias mapping
316        $targetToVariants = [];
317        foreach ( $wgUrlShortenerIdMapping as $variant => $target ) {
318            $targetToVariants[ $target ] ??= [];
319            $targetToVariants[ $target ][] = (string)$variant;
320        }
321
322        // Build a set for each character of possible variants
323        $sets = [];
324        $chars = str_split( $shortcode );
325        foreach ( $chars as $char ) {
326            $set = $targetToVariants[ $char ] ?? [];
327            $set[] = $char;
328            $sets[] = $set;
329        }
330
331        // Cartesian product to get all combinations
332        $productSet = self::cartesianProduct( $sets );
333
334        // Flatten to strings
335        return array_map( static function ( $set ) {
336            return implode( '', $set );
337        }, $productSet );
338    }
339
340    /**
341     * If configured, purge CDN for the given ID
342     *
343     * @param int $id
344     */
345    public static function purgeCdnId( int $id ): void {
346        global $wgUseCdn;
347        if ( $wgUseCdn ) {
348            $codes = array_merge(
349                self::getShortcodeVariants( self::encodeId( $id ) ),
350                self::getShortcodeVariants( self::encodeId( $id, true ) )
351            );
352            foreach ( $codes as $code ) {
353                self::purgeCdn( $code );
354            }
355        }
356    }
357
358    /**
359     * If configured, purge CDN for the given shortcode
360     *
361     * @param string $shortcode
362     */
363    private static function purgeCdn( string $shortcode ): void {
364        global $wgUseCdn;
365        if ( $wgUseCdn ) {
366            $update = new CdnCacheUpdate( [ self::makeUrl( $shortcode ) ] );
367            DeferredUpdates::addUpdate( $update, DeferredUpdates::PRESEND );
368        }
369    }
370
371    public static function getPrimaryDB(): IDatabase {
372        return MediaWikiServices::getInstance()
373            ->getDBLoadBalancerFactory()
374            ->getPrimaryDatabase( 'virtual-urlshortener' );
375    }
376
377    public static function getReplicaDB(): IReadableDatabase {
378        return MediaWikiServices::getInstance()
379            ->getDBLoadBalancerFactory()
380            ->getReplicaDatabase( 'virtual-urlshortener' );
381    }
382
383    /**
384     * Create a fully qualified short URL for the given shortcode.
385     *
386     * @param string $shortCode base64 shortcode to generate URL For.
387     * @return string The fully qualified URL
388     */
389    public static function makeUrl( string $shortCode ): string {
390        global $wgUrlShortenerTemplate, $wgUrlShortenerServer, $wgServer;
391
392        if ( $wgUrlShortenerServer === false ) {
393            $wgUrlShortenerServer = $wgServer;
394        }
395
396        if ( !is_string( $wgUrlShortenerTemplate ) ) {
397            $urlTemplate = SpecialPage::getTitleFor( 'UrlRedirector', '$1' )->getFullUrl();
398        } else {
399            $urlTemplate = $wgUrlShortenerServer . $wgUrlShortenerTemplate;
400        }
401        $url = str_replace( '$1', $shortCode, $urlTemplate );
402
403        // Make sure the URL is fully qualified
404        return wfExpandUrl( $url );
405    }
406
407    /**
408     * Coalesce the regex of allowed domains into a single string regex.
409     *
410     * @return string Regex of allowed domains
411     */
412    public static function getAllowedDomainsRegex(): string {
413        global $wgUrlShortenerAllowedDomains, $wgServer;
414        if ( $wgUrlShortenerAllowedDomains === false ) {
415            // Allowed Domains not configured, default to wgServer
416            $serverParts = wfParseUrl( $wgServer );
417            $allowedDomains = preg_quote( $serverParts['host'], '/' );
418        } else {
419            // Collapse the allowed domains into a single string, so we have to run regex check only once
420            $allowedDomains = implode( '|', array_map(
421                static function ( $item ) {
422                    return '^' . $item . '$';
423                },
424                $wgUrlShortenerAllowedDomains
425            ) );
426        }
427
428        return $allowedDomains;
429    }
430
431    /**
432     * Validates a given URL to see if it is allowed to be used to create a short URL
433     *
434     * @param string $url Url to Validate
435     * @return bool|Message true if it is valid, or error Message object if invalid
436     */
437    public static function validateUrl( string $url ) {
438        global $wgUrlShortenerAllowArbitraryPorts;
439
440        $urlParts = wfParseUrl( $url );
441        if ( $urlParts === false ) {
442            return wfMessage( 'urlshortener-error-malformed-url' );
443        } else {
444            if ( isset( $urlParts['port'] ) && !$wgUrlShortenerAllowArbitraryPorts ) {
445                if ( $urlParts['port'] === 80 || $urlParts['port'] === 443 ) {
446                    unset( $urlParts['port'] );
447                } else {
448                    return wfMessage( 'urlshortener-error-badports' );
449                }
450            }
451
452            if ( isset( $urlParts['user'] ) || isset( $urlParts['pass'] ) ) {
453                return wfMessage( 'urlshortener-error-nouserpass' );
454            }
455
456            $domain = $urlParts['host'];
457
458            if ( preg_match( '/' . self::getAllowedDomainsRegex() . '/', $domain ) === 1 ) {
459                return true;
460            }
461
462            return wfMessage( 'urlshortener-error-disallowed-url' )->params( htmlentities( $domain ) );
463        }
464    }
465
466    /**
467     * Encode an integer into a compact string representation. This is basically
468     * a generalisation of base_convert().
469     *
470     * @param int $x
471     * @param bool $alt Provide an alternate string representation
472     * @return string
473     */
474    public static function encodeId( int $x, bool $alt = false ): string {
475        global $wgUrlShortenerIdSet, $wgUrlShortenerAltPrefix;
476        $s = '';
477        $n = strlen( $wgUrlShortenerIdSet );
478        while ( $x ) {
479            $remainder = $x % $n;
480            $x = ( $x - $remainder ) / $n;
481            $s = $wgUrlShortenerIdSet[$alt ? $n - 1 - $remainder : $remainder] . $s;
482        }
483        return $alt ? $wgUrlShortenerAltPrefix . $s : $s;
484    }
485
486    /**
487     * Decode a compact string to produce an integer, or false if the input is invalid.
488     *
489     * @param string $s
490     * @return int|false
491     */
492    public static function decodeId( string $s ) {
493        global $wgUrlShortenerIdSet, $wgUrlShortenerIdMapping, $wgUrlShortenerAltPrefix;
494
495        if ( $s === '' ) {
496            return false;
497        }
498
499        $alt = false;
500        if ( $s[0] === $wgUrlShortenerAltPrefix ) {
501            $s = substr( $s, 1 );
502            $alt = true;
503        }
504
505        $n = strlen( $wgUrlShortenerIdSet );
506        if ( self::$decodeMap === null ) {
507            self::$decodeMap = [];
508            for ( $i = 0; $i < $n; $i++ ) {
509                self::$decodeMap[$wgUrlShortenerIdSet[$i]] = $i;
510            }
511            foreach ( $wgUrlShortenerIdMapping as $k => $v ) {
512                self::$decodeMap[$k] = self::$decodeMap[$v];
513            }
514        }
515
516        $x = 0;
517        for ( $i = 0, $len = strlen( $s ); $i < $len; $i++ ) {
518            $x *= $n;
519            if ( isset( self::$decodeMap[$s[$i]] ) ) {
520                $val = self::$decodeMap[$s[$i]];
521                $x += $alt ?
522                    $n - 1 - $val :
523                    $val;
524            } else {
525                return false;
526            }
527        }
528        return $x;
529    }
530
531    /**
532     * Given the context of whether we want a QR code, should the URL be shortened?
533     *
534     * @param bool $qrCode
535     * @param string $url
536     * @param int $limit The value of $wgUrlShortenerQrCodeShortenLimit
537     * @return bool
538     */
539    public static function shouldShortenUrl( bool $qrCode, string $url, int $limit ): bool {
540        return !$qrCode || strlen( $url ) > $limit;
541    }
542
543    /**
544     * Build a QR code for the given URL. If the URL is longer than $limit in bytes,
545     * it will first be shortened to prevent the QR code density from being too high.
546     *
547     * @param string $url
548     * @param int $limit The value of $wgUrlShortenerQrCodeShortenLimit
549     * @param User $user User requesting the url, for rate limiting
550     * @param bool $dataUri Return 'qrcode' as a data URI instead of XML.
551     * @return Status Status with 'qrcode' (XML of the SVG) and if applicable, the shortened 'url' and 'alt'.
552     */
553    public static function getQrCode( string $url, int $limit, User $user, bool $dataUri = false ): Status {
554        $shortUrlCode = null;
555        $shortUrlCodeAlt = null;
556        if ( self::shouldShortenUrl( true, $url, $limit ) ) {
557            $status = self::maybeCreateShortCode( $url, $user );
558            if ( !$status->isOK() ) {
559                return $status;
560            }
561            $shortUrlCode = $status->getValue()['url'];
562            $shortUrlCodeAlt = $status->getValue()['alt'];
563            $url = self::makeUrl( $shortUrlCode );
564        } else {
565            $url = self::normalizeUrl( $url );
566        }
567        $qrCode = self::getQrCodeInternal( $url );
568        $res = [
569            'qrcode' => $dataUri ? $qrCode->getDataUri() : $qrCode->getString(),
570        ];
571        if ( $shortUrlCode ) {
572            $res['url'] = $shortUrlCode;
573            $res['alt'] = $shortUrlCodeAlt;
574        }
575        return Status::newGood( $res );
576    }
577
578    private static function getQrCodeInternal( string $url ): ResultInterface {
579        return Builder::create()
580            ->writer( new SvgWriter() )
581            ->writerOptions( [] )
582            ->data( $url )
583            ->encoding( new Encoding( 'UTF-8' ) )
584            ->size( 300 )
585            ->margin( 10 )
586            ->build();
587    }
588}