Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
33.61% |
40 / 119 |
|
6.25% |
1 / 16 |
CRAP | |
0.00% |
0 / 1 |
Util | |
33.61% |
40 / 119 |
|
6.25% |
1 / 16 |
811.99 | |
0.00% |
0 / 1 |
canSetEmail | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
72 | |||
getRelativeTime | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
getIntervals | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
42 | |||
isMobile | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
maybeAddGuidedTour | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
30 | |||
logException | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
logStatus | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
20 | |||
logText | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
2 | |||
getJsonUrl | |
70.59% |
12 / 17 |
|
0.00% |
0 / 1 |
4.41 | |||
getApiUrl | |
75.00% |
18 / 24 |
|
0.00% |
0 / 1 |
7.77 | |||
getRawUrl | |
77.78% |
7 / 9 |
|
0.00% |
0 / 1 |
4.18 | |||
getIteratorFromTraversable | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getRestbaseUrl | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
areLinkRecommendationsEnabled | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
generateRandomToken | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
useCommunityConfiguration | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments; |
4 | |
5 | use Iterator; |
6 | use MediaWiki\Api\ApiRawMessage; |
7 | use MediaWiki\Config\Config; |
8 | use MediaWiki\Context\IContextSource; |
9 | use MediaWiki\Context\RequestContext; |
10 | use MediaWiki\Http\HttpRequestFactory; |
11 | use MediaWiki\Json\FormatJson; |
12 | use MediaWiki\Language\RawMessage; |
13 | use MediaWiki\Linker\LinkTarget; |
14 | use MediaWiki\Logger\LoggerFactory; |
15 | use MediaWiki\MediaWikiServices; |
16 | use MediaWiki\Minerva\Skins\SkinMinerva; |
17 | use MediaWiki\Output\OutputPage; |
18 | use MediaWiki\Parser\Sanitizer; |
19 | use MediaWiki\Registration\ExtensionRegistry; |
20 | use MediaWiki\Status\Status; |
21 | use MediaWiki\Title\TitleFactory; |
22 | use MediaWiki\User\Options\UserOptionsLookup; |
23 | use MediaWiki\User\User; |
24 | use MediaWiki\Utils\UrlUtils; |
25 | use MediaWiki\WikiMap\WikiMap; |
26 | use MWExceptionHandler; |
27 | use Psr\Log\LogLevel; |
28 | use RuntimeException; |
29 | use Skin; |
30 | use StatusValue; |
31 | use Throwable; |
32 | use Traversable; |
33 | use UnexpectedValueException; |
34 | use Wikimedia\NormalizedException\NormalizedException; |
35 | |
36 | class 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 | } |