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