Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
36.04% covered (danger)
36.04%
40 / 111
6.67% covered (danger)
6.67%
1 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Util
36.04% covered (danger)
36.04%
40 / 111
6.67% covered (danger)
6.67%
1 / 15
704.25
0.00% covered (danger)
0.00%
0 / 1
 canSetEmail
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
72
 getRelativeTime
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getIntervals
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
56
 isMobile
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 maybeAddGuidedTour
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 logException
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 logStatus
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
 logText
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getJsonUrl
70.59% covered (warning)
70.59%
12 / 17
0.00% covered (danger)
0.00%
0 / 1
4.41
 getApiUrl
75.00% covered (warning)
75.00%
18 / 24
0.00% covered (danger)
0.00%
0 / 1
7.77
 getRawUrl
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
4.18
 getIteratorFromTraversable
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getRestbaseUrl
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 areLinkRecommendationsEnabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 generateRandomToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace GrowthExperiments;
4
5use ApiRawMessage;
6use FormatJson;
7use IContextSource;
8use Iterator;
9use MediaWiki\Config\Config;
10use MediaWiki\Http\HttpRequestFactory;
11use MediaWiki\Language\RawMessage;
12use MediaWiki\Linker\LinkTarget;
13use MediaWiki\Logger\LoggerFactory;
14use MediaWiki\MediaWikiServices;
15use MediaWiki\Minerva\Skins\SkinMinerva;
16use MediaWiki\Output\OutputPage;
17use MediaWiki\Parser\Sanitizer;
18use MediaWiki\Status\Status;
19use MediaWiki\Title\TitleFactory;
20use MediaWiki\User\Options\UserOptionsLookup;
21use MediaWiki\User\User;
22use MediaWiki\Utils\UrlUtils;
23use MWExceptionHandler;
24use Psr\Log\LogLevel;
25use RequestContext;
26use RuntimeException;
27use Skin;
28use StatusValue;
29use Throwable;
30use Traversable;
31use UnexpectedValueException;
32use Wikimedia\NormalizedException\NormalizedException;
33
34class Util {
35
36    private const MINUTE = 60;
37    private const HOUR = 3600;
38    private const DAY = 86400;
39    private const WEEK = 604800;
40    private const MONTH = 2592000;
41    private const YEAR = 31536000;
42
43    private const STATSD_INCREMENTABLE_ERROR_MESSAGES = [
44        'AddLink' => 'growthexperiments-addlink-notinstore',
45        'AddLinkDuplicate' => 'growthexperiments-addlink-duplicatesubmission',
46        'AddImageNotFound' => 'No recommendation found for page: $1',
47    ];
48
49    /**
50     * Helper method to check if a user can set their email.
51     *
52     * Called from the Help Panel and the Welcome Survey when a user has no email, or has
53     * an email that has not yet been confirmed.
54     *
55     * To check if a user with no email can set a particular email, pass in only the second
56     * argument; to check if a user with an unconfirmed email can set a particular email set the
57     * third argument to false.
58     *
59     * @param User $user
60     * @param string $newEmail
61     * @param bool $checkConfirmedEmail
62     * @return bool
63     */
64    public static function canSetEmail( User $user, string $newEmail = '', $checkConfirmedEmail = true ) {
65        return ( $checkConfirmedEmail ?
66                !$user->getEmail() || !$user->isEmailConfirmed() :
67                !$user->getEmail() ) &&
68            $user->isAllowed( 'viewmyprivateinfo' ) &&
69            $user->isAllowed( 'editmyprivateinfo' ) &&
70            MediaWikiServices::getInstance()->getAuthManager()
71                ->allowsPropertyChange( 'emailaddress' ) &&
72            ( $newEmail ? Sanitizer::validateEmail( $newEmail ) : true );
73    }
74
75    /**
76     * @param IContextSource $contextSource
77     * @param int $elapsedTime
78     * @return string
79     */
80    public static function getRelativeTime( IContextSource $contextSource, $elapsedTime ) {
81        return $contextSource->getLanguage()->formatDuration(
82            $elapsedTime,
83            self::getIntervals( $elapsedTime )
84        );
85    }
86
87    /**
88     * Return the intervals passed as second arg to Language->formatDuration().
89     * @param int $time
90     *  Elapsed time since account creation in seconds.
91     * @return array
92     */
93    private static function getIntervals( $time ) {
94        if ( $time < self::MINUTE ) {
95            return [ 'seconds' ];
96        } elseif ( $time < self::HOUR ) {
97            return [ 'minutes' ];
98        } elseif ( $time < self::DAY ) {
99            return [ 'hours' ];
100        } elseif ( $time < self::WEEK ) {
101            return [ 'days' ];
102        } elseif ( $time < self::MONTH ) {
103            return [ 'weeks' ];
104        } elseif ( $time < self::YEAR ) {
105            return [ 'weeks' ];
106        } else {
107            return [ 'years', 'weeks' ];
108        }
109    }
110
111    /**
112     * @param Skin $skin
113     * @return bool Whether the given skin is considered "mobile".
114     */
115    public static function isMobile( Skin $skin ) {
116        return $skin instanceof SkinMinerva;
117    }
118
119    /**
120     * Add the guided tour module if the user is logged-in, hasn't seen the tour already,
121     * and the tour dependencies are loaded.
122     *
123     * @param OutputPage $out
124     * @param string $pref
125     * @param string|string[] $modules
126     * @param UserOptionsLookup $userOptionsLookup
127     */
128    public static function maybeAddGuidedTour(
129        OutputPage $out,
130        $pref,
131        $modules,
132        UserOptionsLookup $userOptionsLookup
133    ) {
134        if ( $out->getUser()->isNamed()
135            && !$userOptionsLookup->getBoolOption( $out->getUser(), $pref )
136            && TourHooks::growthTourDependenciesLoaded()
137            // Do not show the tour if the user is in the middle of an edit.
138            && !$out->getRequest()->getCookie( 'ge.midEditSignup' )
139        ) {
140            $out->addModules( $modules );
141        }
142    }
143
144    /**
145     * Log an error. Configuration errors are logged to the GrowthExperiments channel,
146     * internal errors are logged to the exception channel.
147     * @param Throwable $exception Error object from the catch block
148     * @param array $extraData
149     * @param string $level Log-level on which WikiConfigException should be logged
150     */
151    public static function logException(
152        Throwable $exception,
153        array $extraData = [],
154        string $level = LogLevel::ERROR
155    ) {
156        // Special-handling for WikiConfigException
157        if ( $exception instanceof WikiConfigException ) {
158            LoggerFactory::getInstance( 'GrowthExperiments' )
159                ->log(
160                    $level,
161                    $exception->getNormalizedMessage(),
162                    $extraData + [ 'exception' => $exception ] + $exception->getMessageContext()
163                );
164        } else {
165            // Normal exception handling
166            MWExceptionHandler::logException( $exception, MWExceptionHandler::CAUGHT_BY_OTHER, $extraData );
167        }
168    }
169
170    /**
171     * Log a StatusValue object, either as a production error or in the GrowthExperiments channel,
172     * depending on its OK flag. Certain errors are also reported to statsd.
173     * @param StatusValue $status
174     * @see ::STATSD_INCREMENTABLE_ERROR_MESSAGES
175     */
176    public static function logStatus( StatusValue $status ) {
177        $statsdDataFactory = MediaWikiServices::getInstance()->getPerDbNameStatsdDataFactory();
178        foreach ( self::STATSD_INCREMENTABLE_ERROR_MESSAGES as $type => $message ) {
179            if ( $status->hasMessage( $message ) ) {
180                $statsdDataFactory->increment( "GrowthExperiments.$type.$message" );
181                break;
182            }
183        }
184
185        [ $errorText, $context ] = Status::wrap( $status )->getPsr3MessageAndContext();
186        if ( $status->isOK() ) {
187            LoggerFactory::getInstance( 'GrowthExperiments' )->error( $errorText, $context );
188        } else {
189            MWExceptionHandler::logException( new NormalizedException( $errorText, $context ),
190                MWExceptionHandler::CAUGHT_BY_OTHER );
191        }
192    }
193
194    /**
195     * Log a string in the GrowthExperiments channel.
196     * @param string $message
197     * @param array $context
198     */
199    public static function logText( string $message, array $context = [] ) {
200        LoggerFactory::getInstance( 'GrowthExperiments' )->error( $message, $context + [
201            'exception' => new RuntimeException,
202        ] );
203    }
204
205    /**
206     * Fetch JSON data from a remote URL, parse it and return the results.
207     * @param HttpRequestFactory $requestFactory
208     * @param string $url
209     * @param bool $isSameFarm Is the URL on the same wiki farm we are making the request from?
210     * @return StatusValue A status object with the parsed JSON value, or any errors.
211     *   (Warnings coming from the HTTP library will be logged and not included here.)
212     */
213    public static function getJsonUrl(
214        HttpRequestFactory $requestFactory, $url, $isSameFarm = false
215    ) {
216        $options = [
217            'method' => 'GET',
218            'userAgent' => $requestFactory->getUserAgent() . ' GrowthExperiments',
219        ];
220        if ( $isSameFarm ) {
221            $options['originalRequest'] = RequestContext::getMain()->getRequest();
222        }
223        $request = $requestFactory->create( $url, $options, __METHOD__ );
224        $status = $request->execute();
225        if ( $status->isOK() ) {
226            $status->merge( FormatJson::parse( $request->getContent(), FormatJson::FORCE_ASSOC ), true );
227        }
228        // Log warnings here. The caller is expected to handle errors so do not double-log them.
229        [ $errorStatus, $warningStatus ] = $status->splitByErrorType();
230        if ( !$warningStatus->isGood() ) {
231            LoggerFactory::getInstance( 'GrowthExperiments' )->warning(
232                $warningStatus->getWikiText( false, false, 'en' ),
233                [ 'exception' => new RuntimeException ]
234            );
235        }
236        return $errorStatus;
237    }
238
239    /**
240     * Fetch data from a remote MediaWiki, parse it and return the results.
241     * Much like getJsonUrl but also handles API errors. GET requests only.
242     * @param HttpRequestFactory $requestFactory
243     * @param string $apiUrl URL of the remote API (should end with 'api.php')
244     * @param (int|string)[] $parameters API parameters. Response formatting parameters will be added.
245     * @param bool $isSameFarm Is the URL on the same wiki farm we are making the request from?
246     * @return StatusValue A status object with the parsed JSON response, or any errors.
247     *   (Warnings will be logged and not included here.)
248     */
249    public static function getApiUrl(
250        HttpRequestFactory $requestFactory,
251        $apiUrl,
252        $parameters,
253        $isSameFarm = false
254    ) {
255        $parameters = [
256            'format' => 'json',
257            'formatversion' => 2,
258            'errorformat' => 'wikitext',
259        ] + $parameters;
260        $status = self::getJsonUrl( $requestFactory, $apiUrl . '?' . wfArrayToCgi( $parameters ),
261            $isSameFarm );
262        if ( $status->isOK() ) {
263            $errorStatus = StatusValue::newGood();
264            $warningStatus = StatusValue::newGood();
265            $data = $status->getValue();
266            if ( isset( $data['errors'] ) ) {
267                foreach ( $data['errors'] as $error ) {
268                    $errorStatus->fatal( new ApiRawMessage( $error['text'], $error['code'] ) );
269                }
270            }
271            if ( isset( $data['warnings'] ) ) {
272                foreach ( $data['warnings'] as $warning ) {
273                    $warningStatus->warning( new RawMessage( $warning['module'] . ': ' . $warning['text'] ) );
274                }
275            }
276            $status->merge( $errorStatus );
277            // Log warnings here. The caller is expected to handle errors so do not double-log them.
278            if ( !$warningStatus->isGood() ) {
279                LoggerFactory::getInstance( 'GrowthExperiments' )->warning(
280                    Status::wrap( $warningStatus )->getWikiText( false, false, 'en' ),
281                    [ 'exception' => new RuntimeException ]
282                );
283            }
284        }
285        return $status;
286    }
287
288    /**
289     * Get the action=raw URL for a (probably remote) title.
290     * Normal title methods would return nice URLs, which are usually disallowed for action=raw.
291     * We assume both wikis use the same URL structure.
292     * @param LinkTarget $title
293     * @param TitleFactory $titleFactory
294     * @return string
295     */
296    public static function getRawUrl(
297        LinkTarget $title,
298        TitleFactory $titleFactory,
299        UrlUtils $urlUtils
300    ) {
301        // Use getFullURL to get the interwiki domain.
302        $url = $titleFactory->newFromLinkTarget( $title )->getFullURL();
303        $parts = $urlUtils->parse( (string)$urlUtils->expand( $url, PROTO_CANONICAL ) );
304        if ( !$parts ) {
305            throw new UnexpectedValueException( 'URL is expected to be valid' );
306        }
307        $baseUrl = $parts['scheme'] . $parts['delimiter'] . $parts['host'];
308        if ( isset( $parts['port'] ) && $parts['port'] ) {
309            $baseUrl .= ':' . $parts['port'];
310        }
311
312        $localPageTitle = $titleFactory->makeTitle( $title->getNamespace(), $title->getDBkey() );
313        return $baseUrl . $localPageTitle->getLocalURL( [ 'action' => 'raw' ] );
314    }
315
316    /**
317     * Convert any traversable to an iterator.
318     * This mainly exists to make Phan happy.
319     * @param Traversable $t
320     * @return Iterator
321     */
322    public static function getIteratorFromTraversable( Traversable $t ) {
323        while ( !( $t instanceof Iterator ) ) {
324            // There are only two traversables, Iterator and IteratorAggregate
325            /** @var \IteratorAggregate $t */'@phan-var \IteratorAggregate $t';
326            $t = $t->getIterator();
327        }
328        // @phan-suppress-next-line PhanTypeMismatchReturnSuperType
329        return $t;
330    }
331
332    /**
333     * Get the URL of the RESTBase (PCS) summary endpoint (without trailing slash).
334     * See https://www.mediawiki.org/wiki/Page_Content_Service#/page/summary
335     * @param Config $config
336     * @return string
337     */
338    public static function getRestbaseUrl( Config $config ) {
339        $url = $config->get( 'GERestbaseUrl' );
340        if ( $url === false ) {
341            $url = $config->get( 'Server' ) . '/api/rest_v1';
342        }
343        return $url;
344    }
345
346    /**
347     * Check whether link recommendations are enabled.
348     * @note While T278123 is in effect, link recommendations can be enabled per-user, and
349     *   most callers should use NewcomerTasksUserOptionsLookup::areLinkRecommendationsEnabled().
350     * @param IContextSource $contextSource
351     * @return bool
352     */
353    public static function areLinkRecommendationsEnabled( IContextSource $contextSource ): bool {
354        return (bool)$contextSource->getConfig()->get( 'GENewcomerTasksLinkRecommendationsEnabled' );
355    }
356
357    /**
358     * Generate a 32 character random token for analytics purposes
359     * @return string
360     */
361    public static function generateRandomToken(): string {
362        return \Wikimedia\base_convert( \MWCryptRand::generateHex( 40 ), 16, 32, 32 );
363    }
364
365}