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