Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
48.80% covered (danger)
48.80%
162 / 332
28.26% covered (danger)
28.26%
13 / 46
CRAP
0.00% covered (danger)
0.00%
0 / 1
MobileContext
48.80% covered (danger)
48.80%
162 / 332
28.26% covered (danger)
28.26%
13 / 46
2292.41
0.00% covered (danger)
0.00%
0 / 1
 singleton
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 resetInstanceForTesting
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 isMobileDevice
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 setForceMobileView
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 loadMobileModeCookie
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getMobileMode
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
56
 setMobileMode
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
30
 isBetaGroupMember
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isAmcUser
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 shouldDisplayMobileView
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 shouldDisplayMobileViewInternal
25.00% covered (danger)
25.00%
5 / 20
0.00% covered (danger)
0.00%
0 / 1
43.17
 getMobileAction
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getUseFormat
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setStopMobileRedirectCookie
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
2
 unsetStopMobileRedirectCookie
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getStopMobileRedirectCookie
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getUseFormatCookie
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getCookieDomain
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getStopMobileRedirectCookieDomain
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 setUseFormatCookie
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 unsetUseFormatCookie
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 getUseFormatCookieExpiry
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 getUseFormatCookieDuration
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getMobileHostToken
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMobileUrlTemplate
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 getMobileUrlCallback
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 hasMobileDomain
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 getMobileUrl
65.52% covered (warning)
65.52%
19 / 29
0.00% covered (danger)
0.00%
0 / 1
15.96
 usingMobileDomain
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getDesktopUrl
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 updateMobileUrlHost
80.95% covered (warning)
80.95%
17 / 21
0.00% covered (danger)
0.00%
0 / 1
8.44
 updateDesktopUrlHost
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 updateDesktopUrlQuery
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 updateMobileUrlPath
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 parseMobileUrlTemplate
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 toggleView
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 doToggling
79.17% covered (warning)
79.17%
19 / 24
0.00% covered (danger)
0.00%
0 / 1
8.58
 checkToggleView
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 isLocalUrl
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 addAnalyticsLogItem
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
 getAnalyticsLogItems
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getXAnalyticsHeader
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 addAnalyticsLogItemFromXAnalytics
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 logMobileMode
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 shouldShowWikibaseDescriptions
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3use MediaWiki\Config\Config;
4use MediaWiki\Context\ContextSource;
5use MediaWiki\Context\IContextSource;
6use MediaWiki\Context\RequestContext;
7use MediaWiki\MediaWikiServices;
8use MobileFrontend\Devices\DeviceDetectorService;
9use MobileFrontend\Hooks\HookRunner;
10use MobileFrontend\WMFBaseDomainExtractor;
11use Wikimedia\IPUtils;
12
13/**
14 * Provide various request-dependant methods to use in mobile context
15 */
16class MobileContext extends ContextSource {
17    public const MODE_BETA = 'beta';
18    public const MODE_STABLE = 'stable';
19    public const OPTIN_COOKIE_NAME = 'optin';
20    public const STOP_MOBILE_REDIRECT_COOKIE_NAME = 'stopMobileRedirect';
21    public const USEFORMAT_COOKIE_NAME = 'mf_useformat';
22    public const USER_MODE_PREFERENCE_NAME = 'mfMode';
23
24    // Keep in sync with https://wikitech.wikimedia.org/wiki/X-Analytics.
25    private const ANALYTICS_HEADER_KEY = 'mf-m';
26    private const ANALYTICS_HEADER_DELIMITER = ',';
27    private const ANALYTICS_HEADER_VALUE_BETA = 'b';
28    private const ANALYTICS_HEADER_VALUE_AMC = 'amc';
29
30    /**
31     * Saves the testing mode user has opted in: 'beta' or 'stable'
32     * @var string|null
33     */
34    protected $mobileMode = null;
35
36    /**
37     * Save explicitly requested format
38     * @var string|null
39     */
40    protected $useFormat = null;
41
42    /**
43     * Key/value pairs of things to add to X-Analytics response header for analytics
44     * @var array[]
45     */
46    protected $analyticsLogItems = [];
47
48    /**
49     * The memoized result of `MobileContext#isMobileDevice`.
50     *
51     * This defaults to `null`, meaning that `MobileContext#isMobileDevice` has
52     * yet to be called.
53     *
54     * @see MobileContext#isMobileDevice
55     *
56     * @var bool|null
57     */
58    private $isMobileDevice = null;
59
60    /**
61     * Saves requested Mobile action
62     * @var string|null
63     */
64    protected $mobileAction = null;
65
66    /**
67     * Save whether mobile view is explicitly requested
68     * @var bool
69     */
70    private $forceMobileView = false;
71
72    /**
73     * Save whether or not we should display the mobile view
74     * @var bool|null
75     */
76    private $mobileView = null;
77
78    /**
79     * Have we already checked for desktop/mobile view toggling?
80     * @var bool
81     */
82    private $toggleViewChecked = false;
83
84    /**
85     * @var self|null
86     */
87    private static $instance = null;
88
89    /**
90     * @var string|null What to switch the view to
91     */
92    private $viewChange = null;
93
94    /**
95     * @var string|null Domain to use for the stopMobileRedirect cookie
96     */
97    public static $mfStopRedirectCookieHost = null;
98
99    /**
100     * @var string|null Stores the actual mobile url template.
101     */
102    private $mobileUrlTemplate = null;
103
104    /**
105     * In-process cache for checking whether the current wiki has a mobile URL that's
106     * different from the desktop one.
107     * @var bool|null
108     */
109    private $hasMobileUrl = null;
110
111    /**
112     * @var Config
113     */
114    private $config;
115
116    /**
117     * Returns the actual MobileContext Instance or create a new if no exists
118     * @deprecated use MediaWikiServices::getInstance()->getService( 'MobileFrontend.Context' );
119     * @return self
120     */
121    public static function singleton() {
122        if ( !self::$instance ) {
123            self::$instance = new self(
124                RequestContext::getMain(),
125                MediaWikiServices::getInstance()->getService( 'MobileFrontend.Config' )
126            );
127        }
128        return self::$instance;
129    }
130
131    /**
132     * Resets the singleton instance.
133     */
134    public static function resetInstanceForTesting() {
135        self::$instance = null;
136    }
137
138    /**
139     * @param IContextSource $context
140     * @param Config $config
141     */
142    protected function __construct( IContextSource $context, Config $config ) {
143        $this->setContext( $context );
144        $this->config = $config;
145    }
146
147    /**
148     * Detects whether the UA is sending the request from a device and, if so,
149     * whether to display the mobile view to that device.
150     *
151     * The mobile view will always be displayed to mobile devices. However, it
152     * will only be displayed to tablet devices if `$wgMFShowMobileViewToTablets`
153     * is truthy.
154     *
155     * @fixme This should be renamed to something more appropriate, e.g.
156     * `shouldDisplayMobileViewToDevice`.
157     *
158     * @see MobileContext::shouldDisplayMobileView
159     *
160     * @return bool
161     */
162    public function isMobileDevice() {
163        if ( $this->isMobileDevice !== null ) {
164            return $this->isMobileDevice;
165        }
166
167        $this->isMobileDevice = false;
168
169        $properties = DeviceDetectorService::factory( $this->config )
170            ->detectDeviceProperties( $this->getRequest(), $_SERVER );
171
172        if ( $properties ) {
173            $showMobileViewToTablets = $this->config->get( 'MFShowMobileViewToTablets' );
174
175            $this->isMobileDevice =
176                $properties->isMobileDevice()
177                || ( $properties->isTabletDevice() && $showMobileViewToTablets );
178        }
179
180        return $this->isMobileDevice;
181    }
182
183    /**
184     * Save whether mobile view should always be enforced
185     * @param bool $value should mobile view be enforced?
186     */
187    public function setForceMobileView( $value ) {
188        $this->forceMobileView = $value;
189    }
190
191    /**
192     * Sets the value of $this->mobileMode property to the value of the 'optin' cookie.
193     * If the cookie is not set the value will be an empty string.
194     */
195    private function loadMobileModeCookie() {
196        $this->mobileMode = $this->getRequest()->getCookie( self::OPTIN_COOKIE_NAME, '' );
197    }
198
199    /**
200     * Returns the testing mode user has opted in: 'beta' or any other value for stable
201     * @return string
202     */
203    private function getMobileMode() {
204        $enableBeta = $this->config->get( 'MFEnableBeta' );
205
206        if ( !$enableBeta ) {
207            return '';
208        }
209        if ( $this->mobileMode === null ) {
210            $mobileAction = $this->getMobileAction();
211            if ( $mobileAction === self::MODE_BETA || $mobileAction === self::MODE_STABLE ) {
212                $this->mobileMode = $mobileAction;
213            } else {
214                $user = $this->getUser();
215                if ( !$user->isRegistered() ) {
216                    $this->loadMobileModeCookie();
217                } else {
218                    $userOptionManager = MediaWikiServices::getInstance()->getUserOptionsManager();
219                    $mode = $userOptionManager->getOption( $user, self::USER_MODE_PREFERENCE_NAME );
220                    $this->mobileMode = $mode;
221                    // Edge case where preferences are corrupt or the user opted
222                    // in before change.
223                    if ( $mode === null ) {
224                        // Should we set the user option here?
225                        $this->loadMobileModeCookie();
226                    }
227                }
228            }
229        }
230        return $this->mobileMode;
231    }
232
233    /**
234     * Sets testing group membership, both cookie and this class variables
235     *
236     * WARNING: Does not persist the updated user preference to the database.
237     * The caller must handle this by calling User::saveSettings() after all
238     * preference updates associated with this web request are made.
239     *
240     * @param string $mode Mode to set
241     */
242    public function setMobileMode( $mode ) {
243        if ( $mode !== self::MODE_BETA ) {
244            $mode = '';
245        }
246        $services = MediaWikiServices::getInstance();
247        $stats = $services->getStatsdDataFactory();
248        // Update statistics
249        if ( $mode === self::MODE_BETA ) {
250            $stats->updateCount( 'mobile.opt_in_cookie_set', 1 );
251        }
252        if ( !$mode ) {
253            $stats->updateCount( 'mobile.opt_in_cookie_unset', 1 );
254        }
255        $this->mobileMode = $mode;
256
257        $user = $this->getUser();
258        if ( $user->getId() ) {
259            $userOptionsManager = $services->getUserOptionsManager();
260            $userOptionsManager->setOption(
261                $user,
262                self::USER_MODE_PREFERENCE_NAME,
263                $mode
264            );
265        }
266
267        $this->getRequest()->response()->setCookie( self::OPTIN_COOKIE_NAME, $mode, 0, [
268            'prefix' => '',
269            'domain' => $this->getCookieDomain()
270        ] );
271    }
272
273    /**
274     * Whether user is Beta group member
275     * @return bool
276     */
277    public function isBetaGroupMember() {
278        return $this->getMobileMode() === self::MODE_BETA;
279    }
280
281    /**
282     * Whether the current user is has advanced mobile contributions enabled.
283     * @return bool
284     */
285    private static function isAmcUser() {
286        $services = MediaWikiServices::getInstance();
287        /** @var \MobileFrontend\Amc\UserMode $userMode */
288        $userMode = $services->getService( 'MobileFrontend.AMC.UserMode' );
289        return $userMode->isEnabled();
290    }
291
292    /**
293     * Determine whether or not we should display the mobile view
294     *
295     * Step through the hierarchy of what should or should not trigger
296     * the mobile view.
297     *
298     * Primacy is given to the page action - we will never show mobile view
299     * for page edits or page history. 'userformat' request param is then
300     * honored, followed by cookie settings, then actual device detection,
301     * finally falling back on false.
302     * @return bool
303     */
304    public function shouldDisplayMobileView() {
305        if ( $this->mobileView !== null ) {
306            return $this->mobileView;
307        }
308        // check if we need to toggle between mobile/desktop view
309        $this->checkToggleView();
310        $this->mobileView = $this->shouldDisplayMobileViewInternal();
311        return $this->mobileView;
312    }
313
314    /**
315     * Value for shouldDisplayMobileView()
316     * @return bool
317     */
318    private function shouldDisplayMobileViewInternal() {
319        // May be overridden programmatically
320        if ( $this->forceMobileView ) {
321            return true;
322        }
323
324        // always display desktop or mobile view if it's explicitly requested
325        $useFormat = $this->getUseFormat();
326        if ( $useFormat == 'desktop' ) {
327            return false;
328        } elseif ( $useFormat == 'mobile' ) {
329            return true;
330        }
331
332        if ( $this->getRequest()->getRawVal( 'mobileformat' ) !== null ) {
333            return true;
334        }
335
336        /**
337         * If a user is accessing the site from a mobile domain, then we should
338         * always display the mobile version of the site (otherwise, the cache
339         * may get polluted). See
340         * https://phabricator.wikimedia.org/T48473
341         */
342        if ( $this->usingMobileDomain() ) {
343            return true;
344        }
345
346        // check cookies for what to display
347        $useMobileFormat = $this->getUseFormatCookie();
348        if ( $useMobileFormat == 'true' ) {
349            return true;
350        }
351        $stopMobileRedirect = $this->getStopMobileRedirectCookie();
352        if ( $stopMobileRedirect == 'true' ) {
353            return false;
354        }
355
356        // do device detection
357        if ( $this->isMobileDevice() ) {
358            return true;
359        }
360
361        return false;
362    }
363
364    /**
365     * Get requested mobile action
366     * @return string
367     */
368    public function getMobileAction() {
369        if ( $this->mobileAction === null ) {
370            $this->mobileAction = $this->getRequest()->getRawVal( 'mobileaction' );
371        }
372
373        return $this->mobileAction;
374    }
375
376    /**
377     * Gets the value of the `useformat` query string parameter.
378     *
379     * @return string Typically "desktop" or "mobile"
380     */
381    private function getUseFormat() {
382        if ( $this->useFormat === null ) {
383            $this->useFormat = $this->getRequest()->getRawVal( 'useformat' );
384        }
385        return $this->useFormat;
386    }
387
388    /**
389     * Set Cookie to stop automatically redirect to mobile page
390     * @param int|null $expiry Expire time of cookie
391     */
392    public function setStopMobileRedirectCookie( $expiry = null ) {
393        $stopMobileRedirectCookieSecureValue =
394            $this->config->get( 'MFStopMobileRedirectCookieSecureValue' );
395
396        $this->getRequest()->response()->setCookie(
397            self::STOP_MOBILE_REDIRECT_COOKIE_NAME,
398            'true',
399            $expiry ?? $this->getUseFormatCookieExpiry(),
400            [
401                'domain' => $this->getStopMobileRedirectCookieDomain(),
402                'prefix' => '',
403                'secure' => (bool)$stopMobileRedirectCookieSecureValue,
404            ]
405        );
406    }
407
408    /**
409     * Remove cookie and continue automatic redirect to mobile page
410     */
411    public function unsetStopMobileRedirectCookie() {
412        if ( $this->getStopMobileRedirectCookie() === null ) {
413            return;
414        }
415        $expire = $this->getUseFormatCookieExpiry( time(), -3600 );
416        $this->setStopMobileRedirectCookie( $expire );
417    }
418
419    /**
420     * Read cookie for stop automatic mobile redirect
421     * @return string
422     */
423    public function getStopMobileRedirectCookie() {
424        $stopMobileRedirectCookie = $this->getRequest()
425            ->getCookie( self::STOP_MOBILE_REDIRECT_COOKIE_NAME, '' );
426
427        return $stopMobileRedirectCookie;
428    }
429
430    /**
431     * This cookie can determine whether or not a user should see the mobile
432     * version of a page.
433     *
434     * @return string|null
435     */
436    public function getUseFormatCookie() {
437        $useFormatFromCookie = $this->getRequest()->getCookie( self::USEFORMAT_COOKIE_NAME, '' );
438
439        return $useFormatFromCookie;
440    }
441
442    /**
443     * Return the base level domain or IP address
444     *
445     * @return string|null
446     */
447    public function getCookieDomain() {
448        $helper = new WMFBaseDomainExtractor();
449        return $helper->getCookieDomain( $this->config->get( 'Server' ) );
450    }
451
452    /**
453     * Determine the correct domain to use for the stopMobileRedirect cookie
454     *
455     * Will use $wgMFStopRedirectCookieHost if it's set, otherwise will use
456     * result of getCookieDomain()
457     * @return string|null
458     */
459    public function getStopMobileRedirectCookieDomain() {
460        $mfStopRedirectCookieHost = $this->config->get( 'MFStopRedirectCookieHost' );
461
462        if ( !$mfStopRedirectCookieHost ) {
463            self::$mfStopRedirectCookieHost = $this->getCookieDomain();
464        } else {
465            self::$mfStopRedirectCookieHost = $mfStopRedirectCookieHost;
466        }
467
468        return self::$mfStopRedirectCookieHost;
469    }
470
471    /**
472     * Set the mf_useformat cookie
473     *
474     * This cookie can determine whether or not a user should see the mobile
475     * version of pages.
476     *
477     * @param string $cookieFormat should user see mobile version of pages?
478     * @param int|null $expiry Expiration of cookie
479     */
480    public function setUseFormatCookie( $cookieFormat = 'true', $expiry = null ) {
481        $this->getRequest()->response()->setCookie(
482            self::USEFORMAT_COOKIE_NAME,
483            $cookieFormat,
484            $expiry ?? $this->getUseFormatCookieExpiry(),
485            [
486                'prefix' => '',
487                'httpOnly' => true,
488            ]
489        );
490        $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
491        $stats->updateCount( 'mobile.useformat_' . $cookieFormat . '_cookie_set', 1 );
492    }
493
494    /**
495     * Remove cookie based saved useformat value
496     */
497    public function unsetUseFormatCookie() {
498        if ( $this->getUseFormatCookie() === null ) {
499            return;
500        }
501
502        // set expiration date in the past
503        $expire = $this->getUseFormatCookieExpiry( time(), -3600 );
504        $this->setUseFormatCookie( '', $expire );
505    }
506
507    /**
508     * Get the expiration time for the mf_useformat cookie
509     *
510     * @param int|null $startTime The base time (in seconds since Epoch) from which to calculate
511     *         cookie expiration. If null, time() is used.
512     * @param int|null $cookieDuration The time (in seconds) the cookie should last
513     * @return int The time (in seconds since Epoch) that the cookie should expire
514     */
515    protected function getUseFormatCookieExpiry( $startTime = null, $cookieDuration = null ) {
516        // use $cookieDuration if it's valid
517        if ( intval( $cookieDuration ) === 0 ) {
518            $cookieDuration = $this->getUseFormatCookieDuration();
519        }
520
521        // use $startTime if it's valid
522        if ( intval( $startTime ) === 0 ) {
523            $startTime = time();
524        }
525
526        $expiry = $startTime + $cookieDuration;
527        return $expiry;
528    }
529
530    /**
531     * Determine the duration the cookie should last.
532     *
533     * If $wgMobileFrontendFormatcookieExpiry has a non-0 value, use that
534     * for the duration. Otherwise, fall back to $wgCookieExpiration.
535     *
536     * @return int The number of seconds for which the cookie should last.
537     */
538    public function getUseFormatCookieDuration() {
539        $mobileFrontendFormatCookieExpiry =
540            $this->config->get( 'MobileFrontendFormatCookieExpiry' );
541
542        $cookieExpiration = $this->getConfig()->get( 'CookieExpiration' );
543
544        $cookieDuration = ( abs( intval( $mobileFrontendFormatCookieExpiry ) ) > 0 ) ?
545            $mobileFrontendFormatCookieExpiry : $cookieExpiration;
546        return $cookieDuration;
547    }
548
549    /**
550     * Take a URL Host Template and return the mobile token portion
551     *
552     * Eg if a desktop domain is en.wikipedia.org, but the mobile variant is
553     * en.m.wikipedia.org, the mobile token is 'm.'
554     *
555     * @param string $mobileUrlHostTemplate URL host
556     * @return string
557     * @deprecated
558     */
559    public function getMobileHostToken( $mobileUrlHostTemplate ) {
560        wfDeprecated( __METHOD__, '1.42', 'MobileFrontend' );
561        return preg_replace( '/%h[0-9]\.{0,1}/', '', $mobileUrlHostTemplate );
562    }
563
564    /**
565     * Get the template for mobile URLs.
566     * @see $wgMobileUrlTemplate
567     * @return string
568     * @deprecated In other classes, use hasMobileUrl() instead.
569     */
570    private function getMobileUrlTemplate() {
571        if ( $this->mobileUrlTemplate === null ) {
572            $this->mobileUrlTemplate = $this->config->get( 'MobileUrlTemplate' );
573        }
574        // Only issue deprecation warning when $wgMobileUrlTemplate is set.
575        // Neither $wgMobileUrlTemplate nor $wgMobileUrlCallback being set is a valid
576        // non-deprecated scenario, and in that case hasMobileDomain() will call this
577        // method as fallback.
578        if ( $this->mobileUrlTemplate ) {
579            wfDeprecated( __METHOD__, '1.42', 'MobileFrontend' );
580        }
581        return $this->mobileUrlTemplate;
582    }
583
584    /**
585     * Returns the callback from $wgMobileUrlCallback, which changes
586     *   a desktop domain into a mobile domain.
587     * @return callable|null
588     * @phan-return callable(string):string|null
589     */
590    private function getMobileUrlCallback(): ?callable {
591        return $this->config->get( 'MobileUrlCallback' );
592    }
593
594    /**
595     * True if the current wiki has a mobile URL that's different from the desktop one.
596     * @return bool
597     */
598    public function hasMobileDomain(): bool {
599        if ( $this->hasMobileUrl === null ) {
600            $mobileUrlCallback = $this->getMobileUrlCallback();
601            if ( $mobileUrlCallback ) {
602                $server = wfExpandUrl( $this->getConfig()->get( 'Server' ), PROTO_CANONICAL );
603                $serverParts = wfParseUrl( $server );
604                $mobileDomain = call_user_func( $mobileUrlCallback, $serverParts['host'] );
605                $this->hasMobileUrl = $mobileDomain !== $serverParts['host'];
606            } else {
607                $this->hasMobileUrl = $this->getMobileUrlTemplate() !== '';
608            }
609        }
610        return $this->hasMobileUrl;
611    }
612
613    /**
614     * Take a URL and return a copy that conforms to the mobile URL template
615     * @param string $url URL to convert
616     * @param bool $forceHttps should force HTTPS?
617     * @return string|bool
618     */
619    public function getMobileUrl( $url, $forceHttps = false ) {
620        if ( $this->shouldDisplayMobileView() ) {
621            $subdomainTokenReplacement = null;
622            $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
623            if ( $hookRunner->onGetMobileUrl( $subdomainTokenReplacement, $this ) ) {
624                if ( $subdomainTokenReplacement !== null ) {
625                    $mobileUrlHostTemplate = $this->parseMobileUrlTemplate( 'host' );
626                    $mobileToken = $this->getMobileHostToken( $mobileUrlHostTemplate );
627                    $this->mobileUrlTemplate = str_replace(
628                        $mobileToken,
629                        $subdomainTokenReplacement,
630                        $this->getMobileUrlTemplate()
631                    );
632                }
633            }
634        }
635
636        $parsedUrl = wfParseUrl( $url );
637        // if parsing failed, maybe it's a local Url, try to expand and reparse it - task T107505
638        if ( !$parsedUrl ) {
639            $expandedUrl = wfExpandUrl( $url );
640            if ( $expandedUrl ) {
641                $parsedUrl = wfParseUrl( $expandedUrl );
642            }
643            if ( !$expandedUrl || !$parsedUrl ) {
644                return false;
645            }
646        }
647
648        $mobileUrlCallback = $this->getMobileUrlCallback();
649        if ( $mobileUrlCallback ) {
650            $parsedUrl['host'] = call_user_func( $mobileUrlCallback, $parsedUrl['host'] );
651        } elseif ( $this->getMobileUrlTemplate() ) {
652            $this->updateMobileUrlHost( $parsedUrl );
653        }
654        if ( $forceHttps ) {
655            $parsedUrl['scheme'] = 'https';
656            $parsedUrl['delimiter'] = '://';
657        }
658
659        $assembleUrl = wfAssembleUrl( $parsedUrl );
660        return $assembleUrl;
661    }
662
663    /**
664     * If a mobile-domain is specified by the $wgMobileUrlTemplate and
665     * there's a mobile header, then we assume the user is accessing
666     * the site from the mobile-specific domain (because why would the
667     * desktop site set the header?).
668     * @return bool
669     */
670    public function usingMobileDomain() {
671        $mobileHeader = $this->config->get( 'MFMobileHeader' );
672        return ( $this->hasMobileDomain()
673            && $mobileHeader
674            && $this->getRequest()->getHeader( $mobileHeader ) !== false
675        );
676    }
677
678    /**
679     * Take a URL and return a copy that removes any mobile tokens
680     * @param string $url representing a page on the mobile domain e.g. `https://en.m.wikipedia.org/`
681     * @return string (absolute url)
682     */
683    public function getDesktopUrl( $url ) {
684        $parsedUrl = wfParseUrl( $url );
685        $this->updateDesktopUrlHost( $parsedUrl );
686        $this->updateDesktopUrlQuery( $parsedUrl );
687        $desktopUrl = wfAssembleUrl( $parsedUrl );
688        return $desktopUrl;
689    }
690
691    /**
692     * Update host of given URL to conform to mobile URL template.
693     * @param array &$parsedUrl Result of parseUrl() or wfParseUrl()
694     * @deprecated
695     */
696    protected function updateMobileUrlHost( array &$parsedUrl ) {
697        wfDeprecated( __METHOD__, '1.42', 'MobileFrontend' );
698
699        if ( IPUtils::isIPAddress( $parsedUrl['host'] ) ) {
700            // Do not update host when IP is used
701            return;
702        }
703        $mobileUrlHostTemplate = $this->parseMobileUrlTemplate( 'host' );
704        if ( !strlen( $mobileUrlHostTemplate ) ) {
705            return;
706        }
707
708        $parsedHostParts = explode( ".", $parsedUrl['host'] );
709        $templateHostParts = explode( ".", $mobileUrlHostTemplate );
710        $targetHostParts = [];
711
712        foreach ( $templateHostParts as $key => $templateHostPart ) {
713            if ( strstr( $templateHostPart, '%h' ) ) {
714                $parsedHostPartKey = (int)substr( $templateHostPart, 2 );
715                if ( !array_key_exists( $parsedHostPartKey, $parsedHostParts ) ) {
716                    // invalid pattern for this host, ignore
717                    return;
718                }
719                $targetHostParts[$key] = $parsedHostParts[$parsedHostPartKey];
720            } elseif ( isset( $parsedHostParts[$key] )
721                && $templateHostPart == $parsedHostParts[$key] ) {
722                $targetHostParts = $parsedHostParts;
723                break;
724            } else {
725                $targetHostParts[$key] = $templateHostPart;
726            }
727        }
728
729        $parsedUrl['host'] = implode( ".", $targetHostParts );
730    }
731
732    /**
733     * Update the host of a given URL to strip out any mobile tokens
734     * @param array &$parsedUrl Result of parseUrl() or wfParseUrl()
735     */
736    protected function updateDesktopUrlHost( array &$parsedUrl ) {
737        $server = $this->getConfig()->get( 'Server' );
738
739        if ( !$this->hasMobileDomain() ) {
740            return;
741        }
742
743        $parsedWgServer = wfParseUrl( $server );
744        $parsedUrl['host'] = $parsedWgServer['host'];
745    }
746
747    /**
748     * Update the query portion of a given URL to remove any 'useformat' params
749     * @param array &$parsedUrl Result of parseUrl() or wfParseUrl()
750     */
751    protected function updateDesktopUrlQuery( array &$parsedUrl ) {
752        if ( isset( $parsedUrl['query'] ) && strpos( $parsedUrl['query'], 'useformat' ) !== false ) {
753            $query = wfCgiToArray( $parsedUrl['query'] );
754            unset( $query['useformat'] );
755            $parsedUrl['query'] = wfArrayToCgi( $query );
756        }
757    }
758
759    /**
760     * Update path of given URL to conform to mobile URL template.
761     *
762     * NB: this is not actually being used anywhere at the moment. It will
763     * take some magic to get MW to properly handle path modifications like
764     * this is intended to provide. This will hopefully be implemented someday
765     * in the not to distant future.
766     *
767     * @param array &$parsedUrl Result of parseUrl() or wfParseUrl()
768     * @deprecated
769     */
770    protected function updateMobileUrlPath( array &$parsedUrl ) {
771        wfDeprecated( __METHOD__, '1.42', 'MobileFrontend' );
772
773        $scriptPath = $this->getConfig()->get( 'ScriptPath' );
774
775        $mobileUrlPathTemplate = $this->parseMobileUrlTemplate( 'path' );
776
777        // if there's no path template, no reason to continue.
778        if ( !strlen( $mobileUrlPathTemplate ) ) {
779            return;
780        }
781
782        // find out if we already have a templated path
783        $templatePathOffset = strpos( $mobileUrlPathTemplate, '%p' );
784        $templatePathSansToken = substr( $mobileUrlPathTemplate, 0, $templatePathOffset );
785        if ( substr_compare( $parsedUrl['path'], $scriptPath . $templatePathSansToken, 0 ) > 0 ) {
786            return;
787        }
788
789        $scriptPathLength = strlen( $scriptPath );
790        // the "+ 1" removes the preceding "/" from the path sans $wgScriptPath
791        $pathSansScriptPath = substr( $parsedUrl['path'], $scriptPathLength + 1 );
792        $parsedUrl['path'] = $scriptPath . $templatePathSansToken . $pathSansScriptPath;
793    }
794
795    /**
796     * Parse mobile URL template into its host and path components.
797     *
798     * Optionally specify which portion of the template you want returned.
799     * @param string|null $part which part to return?
800     * @return mixed
801     * @deprecated
802     */
803    public function parseMobileUrlTemplate( $part = null ) {
804        wfDeprecated( __METHOD__, '1.42', 'MobileFrontend' );
805
806        $mobileUrlTemplate = $this->getMobileUrlTemplate();
807
808        $pathStartPos = strpos( $mobileUrlTemplate, '/' );
809
810        /**
811         * This if/else block exists because of an annoying aspect of substr()
812         * Even if you pass 'null' or 'false' into the 'length' param, it
813         * will return an empty string.
814         * http://www.stopgeek.com/wp-content/uploads/2007/07/sense.jpg
815         */
816        if ( $pathStartPos === false ) {
817            $host = substr( $mobileUrlTemplate, 0 );
818        } else {
819            $host = substr( $mobileUrlTemplate, 0, $pathStartPos );
820        }
821
822        $path = substr( $mobileUrlTemplate, $pathStartPos );
823
824        if ( $part == 'host' ) {
825            return $host;
826        } elseif ( $part == 'path' ) {
827            return $path;
828        } else {
829            return [ 'host' => $host, 'path' => $path ];
830        }
831    }
832
833    /**
834     * Toggles view to one specified by the user
835     *
836     * If a user has requested a particular view (eg clicked 'Desktop' from
837     * a mobile page), set the requested view for this particular request
838     * and set a cookie to keep them on that view for subsequent requests.
839     *
840     * @param string $view User requested particular view
841     */
842    public function toggleView( $view ) {
843        $this->viewChange = $view;
844        if ( !$this->hasMobileDomain() ) {
845            $this->useFormat = $view;
846        }
847    }
848
849    /**
850     * Performs view change as requested vy toggleView()
851     */
852    public function doToggling() {
853        // make sure viewChange is set
854        $this->shouldDisplayMobileView();
855
856        if ( !$this->viewChange ) {
857            return;
858        }
859
860        $title = $this->getTitle();
861        if ( !$title ) {
862            return;
863        }
864
865        $query = $this->getRequest()->getQueryValues();
866        unset( $query['mobileaction'] );
867        unset( $query['useformat'] );
868        unset( $query['title'] );
869        $url = $title->getFullURL( $query, false, PROTO_CURRENT );
870
871        if ( $this->viewChange == 'mobile' ) {
872            // unset stopMobileRedirect cookie
873            // @TODO is this necessary with unsetting the cookie via JS?
874            $this->unsetStopMobileRedirectCookie();
875
876            // if no mobile domain support, set mobile cookie
877            if ( !$this->hasMobileDomain() ) {
878                $this->setUseFormatCookie();
879            } else {
880                // else redirect to mobile domain
881                $mobileUrl = $this->getMobileUrl( $url );
882                $this->getOutput()->redirect( $mobileUrl, 301 );
883            }
884        } elseif ( $this->viewChange == 'desktop' ) {
885            // set stopMobileRedirect cookie
886            $this->setStopMobileRedirectCookie();
887            // unset useformat cookie
888            if ( $this->getUseFormatCookie() == "true" ) {
889                $this->unsetUseFormatCookie();
890            }
891
892            if ( $this->hasMobileDomain() ) {
893                // if there is mobile domain support, redirect to desktop domain
894                $desktopUrl = $this->getDesktopUrl( $url );
895                $this->getOutput()->redirect( $desktopUrl, 301 );
896            }
897        }
898    }
899
900    /**
901     * Determine whether or not we need to toggle the view, and toggle it
902     */
903    public function checkToggleView() {
904        if ( !$this->toggleViewChecked ) {
905            $this->toggleViewChecked = true;
906            $mobileAction = $this->getMobileAction();
907            if ( $mobileAction == 'toggle_view_desktop' ) {
908                $this->toggleView( 'desktop' );
909            } elseif ( $mobileAction == 'toggle_view_mobile' ) {
910                $this->toggleView( 'mobile' );
911            }
912        }
913    }
914
915    /**
916     * Determine whether or not a given URL is local
917     *
918     * @param string $url URL to check against
919     * @return bool
920     */
921    public function isLocalUrl( $url ) {
922        $parsedTarget = wfParseUrl( $url );
923        $parsedServer = wfParseUrl( $this->config->get( 'Server' ) );
924        return $parsedTarget['host'] === $parsedServer['host'];
925    }
926
927    /**
928     * Add key/value pairs for analytics purposes to $this->analyticsLogItems. Pre-existing entries
929     * are appended to as sets delimited by commas.
930     * @param string $key for <key> in `X-Analytics: <key>=<value>`
931     * @param string $val for <value> in `X-Analytics: <key>=<value>`
932     */
933    public function addAnalyticsLogItem( $key, $val ) {
934        $key = trim( $key );
935        $val = trim( $val );
936        $items = $this->analyticsLogItems[$key] ?? [];
937        if ( !in_array( $val, $items ) ) {
938            $items[] = $val;
939            $this->analyticsLogItems[$key] = $items;
940        }
941    }
942
943    /**
944     * Read key/value pairs for analytics purposes from $this->analyticsLogItems
945     * @return array
946     */
947    public function getAnalyticsLogItems() {
948        return array_map(
949            static function ( $val ) {
950                return implode( self::ANALYTICS_HEADER_DELIMITER, $val );
951            },
952            $this->analyticsLogItems
953        );
954    }
955
956    /**
957     * Get HTTP header string for X-Analytics
958     *
959     * This is made up of key/value pairs and is used for analytics purposes.
960     *
961     * @return string|bool
962     */
963    public function getXAnalyticsHeader() {
964        $response = $this->getRequest()->response();
965        $currentHeader = method_exists( $response, 'getHeader' ) ?
966            (string)$response->getHeader( 'X-Analytics' ) : '';
967        parse_str( preg_replace( '/; */', '&', $currentHeader ), $logItems );
968        $logItems += $this->getAnalyticsLogItems();
969        if ( count( $logItems ) ) {
970            $xanalytics_items = [];
971            foreach ( $logItems as $key => $val ) {
972                $xanalytics_items[] = urlencode( $key ) . "=" . urlencode( $val );
973            }
974            $headerValue = implode( ';', $xanalytics_items );
975            return "X-Analytics: $headerValue";
976        } else {
977            return false;
978        }
979    }
980
981    /**
982     * Take a key/val pair in string format and add it to $this->analyticsLogItems
983     *
984     * @param string $xanalytics_item In the format key=value
985     */
986    public function addAnalyticsLogItemFromXAnalytics( $xanalytics_item ) {
987        [ $key, $val ] = explode( '=', $xanalytics_item, 2 );
988        $this->addAnalyticsLogItem( urldecode( $key ), urldecode( $val ) );
989    }
990
991    /**
992     * Adds analytics log items depending on which modes are enabled for the user
993     *
994     * Invoked from MobileFrontendHooks::onRequestContextCreateSkin()
995     *
996     * Making changes to what this method logs? Make sure you update the
997     * documentation for the X-Analytics header: https://wikitech.wikimedia.org/wiki/X-Analytics
998     */
999    public function logMobileMode() {
1000        if ( $this->isBetaGroupMember() ) {
1001            $this->addAnalyticsLogItem( self::ANALYTICS_HEADER_KEY, self::ANALYTICS_HEADER_VALUE_BETA );
1002        }
1003        if ( self::isAmcUser() ) {
1004            $this->addAnalyticsLogItem( self::ANALYTICS_HEADER_KEY, self::ANALYTICS_HEADER_VALUE_AMC );
1005        }
1006    }
1007
1008    /**
1009     * Gets whether Wikibase descriptions should be shown in search results,
1010     * and watchlists; or as taglines on article pages.
1011     * Doesn't take into account whether the wikidata descriptions
1012     * feature has been enabled.
1013     *
1014     * @param string $feature which description to show?
1015     * @param Config $config
1016     * @return bool
1017     * @throws DomainException If `feature` isn't one that shows Wikidata descriptions. See the
1018     *  `wgMFDisplayWikibaseDescriptions` configuration variable for detail
1019     */
1020    public function shouldShowWikibaseDescriptions( $feature, Config $config ) {
1021        $displayWikibaseDescriptions = $config->get( 'MFDisplayWikibaseDescriptions' );
1022        if ( !isset( $displayWikibaseDescriptions[$feature] ) ) {
1023            throw new DomainException(
1024                "\"{$feature}\" isn't a feature that shows Wikidata descriptions."
1025            );
1026        }
1027
1028        return $displayWikibaseDescriptions[$feature];
1029    }
1030}