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