Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
36.04% |
40 / 111 |
|
6.67% |
1 / 15 |
CRAP | |
0.00% |
0 / 1 |
Util | |
36.04% |
40 / 111 |
|
6.67% |
1 / 15 |
704.25 | |
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 / 13 |
|
0.00% |
0 / 1 |
56 | |||
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 / 10 |
|
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 |
1 | <?php |
2 | |
3 | namespace GrowthExperiments; |
4 | |
5 | use ApiRawMessage; |
6 | use FormatJson; |
7 | use IContextSource; |
8 | use Iterator; |
9 | use MediaWiki\Config\Config; |
10 | use MediaWiki\Http\HttpRequestFactory; |
11 | use MediaWiki\Language\RawMessage; |
12 | use MediaWiki\Linker\LinkTarget; |
13 | use MediaWiki\Logger\LoggerFactory; |
14 | use MediaWiki\MediaWikiServices; |
15 | use MediaWiki\Minerva\Skins\SkinMinerva; |
16 | use MediaWiki\Output\OutputPage; |
17 | use MediaWiki\Parser\Sanitizer; |
18 | use MediaWiki\Status\Status; |
19 | use MediaWiki\Title\TitleFactory; |
20 | use MediaWiki\User\Options\UserOptionsLookup; |
21 | use MediaWiki\User\User; |
22 | use MediaWiki\Utils\UrlUtils; |
23 | use MWExceptionHandler; |
24 | use Psr\Log\LogLevel; |
25 | use RequestContext; |
26 | use RuntimeException; |
27 | use Skin; |
28 | use StatusValue; |
29 | use Throwable; |
30 | use Traversable; |
31 | use UnexpectedValueException; |
32 | use Wikimedia\NormalizedException\NormalizedException; |
33 | |
34 | class 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 | } |