Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
92.04% |
185 / 201 |
|
68.75% |
11 / 16 |
CRAP | |
0.00% |
0 / 1 |
RightsLogFormatter | |
92.50% |
185 / 200 |
|
68.75% |
11 / 16 |
61.52 | |
0.00% |
0 / 1 |
makePageLink | |
42.11% |
8 / 19 |
|
0.00% |
0 / 1 |
9.85 | |||
getMessageKey | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
getMessageParameters | |
95.24% |
20 / 21 |
|
0.00% |
0 / 1 |
5 | |||
shouldProcessParams | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
getOldGroups | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
getNewGroups | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
2.06 | |||
joinGroupsWithExpiries | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
5 | |||
replaceGroupsWithMemberNames | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
classifyGroupChanges | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
3 | |||
formatChangesToGroups | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
4 | |||
formatRightsListExpiryChanged | |
96.77% |
30 / 31 |
|
0.00% |
0 / 1 |
8 | |||
formatRightsList | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
3 | |||
formatDate | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
getParametersForApi | |
100.00% |
35 / 35 |
|
100.00% |
1 / 1 |
7 | |||
formatParametersForApi | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
5 | |||
makeGroupArray | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 |
1 | <?php |
2 | /** |
3 | * Formatter for user rights log entries. |
4 | * |
5 | * This program is free software; you can redistribute it and/or modify |
6 | * it under the terms of the GNU General Public License as published by |
7 | * the Free Software Foundation; either version 2 of the License, or |
8 | * (at your option) any later version. |
9 | * |
10 | * This program is distributed in the hope that it will be useful, |
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
13 | * GNU General Public License for more details. |
14 | * |
15 | * You should have received a copy of the GNU General Public License along |
16 | * with this program; if not, write to the Free Software Foundation, Inc., |
17 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
18 | * http://www.gnu.org/copyleft/gpl.html |
19 | * |
20 | * @file |
21 | * @author Alexandre Emsenhuber |
22 | * @license GPL-2.0-or-later |
23 | * @since 1.22 |
24 | */ |
25 | |
26 | namespace MediaWiki\Logging; |
27 | |
28 | use MediaWiki\Api\ApiResult; |
29 | use MediaWiki\MainConfigNames; |
30 | use MediaWiki\Message\Message; |
31 | use MediaWiki\Title\Title; |
32 | use MediaWiki\WikiMap\WikiMap; |
33 | |
34 | /** |
35 | * This class formats rights log entries. |
36 | * |
37 | * @stable to extend Since 1.44 |
38 | * @since 1.21 |
39 | */ |
40 | class RightsLogFormatter extends LogFormatter { |
41 | protected function makePageLink( ?Title $title = null, $parameters = [], $html = null ) { |
42 | $userrightsInterwikiDelimiter = $this->context->getConfig() |
43 | ->get( MainConfigNames::UserrightsInterwikiDelimiter ); |
44 | |
45 | if ( !$this->plaintext ) { |
46 | $text = $this->getContentLanguage()-> |
47 | ucfirst( $title->getDBkey() ); |
48 | $parts = explode( $userrightsInterwikiDelimiter, $text, 2 ); |
49 | |
50 | if ( count( $parts ) === 2 ) { |
51 | // @phan-suppress-next-line SecurityCheck-DoubleEscaped |
52 | $titleLink = WikiMap::foreignUserLink( |
53 | $parts[1], |
54 | $parts[0], |
55 | htmlspecialchars( |
56 | strtr( $parts[0], '_', ' ' ) . |
57 | $userrightsInterwikiDelimiter . |
58 | $parts[1] |
59 | ) |
60 | ); |
61 | |
62 | if ( $titleLink !== false ) { |
63 | return $titleLink; |
64 | } |
65 | } |
66 | } |
67 | |
68 | return parent::makePageLink( $title, $parameters, $title ? $title->getText() : null ); |
69 | } |
70 | |
71 | protected function getMessageKey() { |
72 | $key = parent::getMessageKey(); |
73 | $params = $this->getMessageParameters(); |
74 | if ( !isset( $params[3] ) && !isset( $params[4] ) ) { |
75 | // Messages: logentry-rights-rights-legacy |
76 | $key .= '-legacy'; |
77 | } |
78 | |
79 | return $key; |
80 | } |
81 | |
82 | protected function getMessageParameters() { |
83 | $params = parent::getMessageParameters(); |
84 | |
85 | // Really old entries that lack old/new groups, |
86 | // so don't try to process them |
87 | if ( !$this->shouldProcessParams( $params ) ) { |
88 | return $params; |
89 | } |
90 | |
91 | // Groups are stored as [ name => expiry|null ] |
92 | $oldGroups = $this->getOldGroups( $params ); |
93 | $newGroups = $this->getNewGroups( $params ); |
94 | |
95 | // These params were used in the past, when the log message said "from: X to: Y" |
96 | // They were kept not to break translations |
97 | if ( count( $oldGroups ) ) { |
98 | $params[3] = Message::rawParam( $this->formatRightsList( $oldGroups ) ); |
99 | } else { |
100 | $params[3] = $this->msg( 'rightsnone' )->text(); |
101 | } |
102 | if ( count( $newGroups ) ) { |
103 | $params[4] = Message::rawParam( $this->formatRightsList( $newGroups ) ); |
104 | } else { |
105 | $params[4] = $this->msg( 'rightsnone' )->text(); |
106 | } |
107 | |
108 | $performerName = $params[1]; |
109 | $userName = $this->entry->getTarget()->getText(); |
110 | |
111 | $params[5] = $userName; |
112 | |
113 | $groupChanges = $this->classifyGroupChanges( $oldGroups, $newGroups ); |
114 | |
115 | // The following messages are used here: |
116 | // * logentry-rights-rights-granted |
117 | // * logentry-rights-rights-revoked |
118 | // * logentry-rights-rights-expiry-changed |
119 | // * logentry-rights-rights-kept |
120 | // * logentry-rights-autopromote-granted |
121 | // * logentry-rights-autopromote-revoked |
122 | // * logentry-rights-autopromote-expiry-changed |
123 | // * logentry-rights-autopromote-kept |
124 | $messagePrefix = 'logentry-rights-rights-'; |
125 | if ( $this->entry->getSubtype() === 'autopromote' ) { |
126 | $messagePrefix = 'logentry-rights-autopromote-'; |
127 | } |
128 | |
129 | $params[6] = $this->formatChangesToGroups( $groupChanges, $performerName, $userName, |
130 | $messagePrefix ); |
131 | |
132 | return $params; |
133 | } |
134 | |
135 | /** |
136 | * Checks whether the additional message parameters should be processed. |
137 | * Typical reason for not processing the parameters is that the log entry |
138 | * is of legacy format with e.g. some of them missing. |
139 | * @since 1.44 |
140 | * @stable to override |
141 | * @param array $params Extracted parameters |
142 | * @return bool |
143 | */ |
144 | protected function shouldProcessParams( array $params ) { |
145 | return isset( $params[3] ) || isset( $params[4] ); |
146 | } |
147 | |
148 | /** |
149 | * Returns the old groups related to this log entry together |
150 | * with their expiry times. The returned array is indexed by the |
151 | * group name in a ready-to-display form (eg. localized) |
152 | * @since 1.44 |
153 | * @stable to override |
154 | * @param array $params Extracted parameters |
155 | * @return array [ group_name => expiry|null ] |
156 | */ |
157 | protected function getOldGroups( array $params ) { |
158 | if ( !isset( $params[3] ) ) { |
159 | return []; |
160 | } |
161 | |
162 | $allParams = $this->entry->getParameters(); |
163 | return $this->joinGroupsWithExpiries( $params[3], $allParams['oldmetadata'] ?? [] ); |
164 | } |
165 | |
166 | /** |
167 | * Returns the new groups related to this log entry together |
168 | * with their expiry times. The returned array is indexed by the |
169 | * group name in a ready-to-display form (eg. localized) |
170 | * @since 1.44 |
171 | * @stable to override |
172 | * @param array $params Extracted parameters |
173 | * @return array [ group_name => expiry|null ] |
174 | */ |
175 | protected function getNewGroups( array $params ) { |
176 | if ( !isset( $params[4] ) ) { |
177 | return []; |
178 | } |
179 | |
180 | $allParams = $this->entry->getParameters(); |
181 | return $this->joinGroupsWithExpiries( $params[4], $allParams['newmetadata'] ?? [] ); |
182 | } |
183 | |
184 | /** |
185 | * Joins group names from one array with their expiry times from the another. |
186 | * Expects that corresponding elements in both arrays are at the same index. |
187 | * The expiry times are looked up in the 'expiry' key of the elements int the |
188 | * metadata array. If membership is permanent, the expiry time is null. |
189 | * If this formatter is not plaintext, the group names are replaced with |
190 | * localized member names. |
191 | * @since 1.44 |
192 | * @param array|string $groupNames |
193 | * @param array $metadata |
194 | * @return array |
195 | */ |
196 | protected function joinGroupsWithExpiries( $groupNames, array $metadata ) { |
197 | $groupNames = $this->makeGroupArray( $groupNames ); |
198 | if ( !$this->plaintext && count( $groupNames ) ) { |
199 | $this->replaceGroupsWithMemberNames( $groupNames ); |
200 | } |
201 | |
202 | $expiries = []; |
203 | foreach ( |
204 | array_map( null, $groupNames, $metadata ) |
205 | as [ $groupName, $groupMetadata ] |
206 | ) { |
207 | if ( isset( $groupMetadata['expiry'] ) ) { |
208 | $expiry = $groupMetadata['expiry']; |
209 | } else { |
210 | $expiry = null; |
211 | } |
212 | $expiries[$groupName] = $expiry; |
213 | } |
214 | return $expiries; |
215 | } |
216 | |
217 | /** |
218 | * Replaces the group names in the array with their localized member names. |
219 | * The array is modified in place. |
220 | * @since 1.44 |
221 | * @stable to override |
222 | * @param array &$groupNames |
223 | */ |
224 | protected function replaceGroupsWithMemberNames( array &$groupNames ) { |
225 | $lang = $this->context->getLanguage(); |
226 | $userName = $this->entry->getTarget()->getText(); |
227 | foreach ( $groupNames as &$group ) { |
228 | $group = $lang->getGroupMemberName( $group, $userName ); |
229 | } |
230 | } |
231 | |
232 | /** |
233 | * Compares the user groups from before and after this log entry and splits |
234 | * them into four categories: granted, revoked, expiry-changed and kept. |
235 | * The returned array has the following keys: |
236 | * - granted: groups that were granted |
237 | * - revoked: groups that were revoked |
238 | * - expiry-changed: groups that had their expiry time changed |
239 | * - kept: groups that were kept without changes |
240 | * All, except 'expiry-changed', is of form [ group => expiry ], while |
241 | * 'expiry-changed' is of form [ group => [ old_expiry, new_expiry ] ] |
242 | * @since 1.44 |
243 | * @param array $oldGroups |
244 | * @param array $newGroups |
245 | * @return array |
246 | */ |
247 | protected function classifyGroupChanges( array $oldGroups, array $newGroups ) { |
248 | $granted = array_diff_key( $newGroups, $oldGroups ); |
249 | $revoked = array_diff_key( $oldGroups, $newGroups ); |
250 | $kept = array_intersect_key( $oldGroups, $newGroups ); |
251 | |
252 | $expiryChanged = []; |
253 | $noChange = []; |
254 | |
255 | foreach ( $kept as $group => $oldExpiry ) { |
256 | $newExpiry = $newGroups[$group]; |
257 | if ( $oldExpiry !== $newExpiry ) { |
258 | $expiryChanged[$group] = [ $oldExpiry, $newExpiry ]; |
259 | } else { |
260 | $noChange[$group] = $oldExpiry; |
261 | } |
262 | } |
263 | |
264 | // These contain both group names and their expiry times |
265 | // in case of 'expiry-changed', the times are in an array [ old, new ] |
266 | return [ |
267 | 'granted' => $granted, |
268 | 'revoked' => $revoked, |
269 | 'expiry-changed' => $expiryChanged, |
270 | 'kept' => $noChange, |
271 | ]; |
272 | } |
273 | |
274 | /** |
275 | * Wraps the changes to user groups into a human-readable messages, so that |
276 | * they can be passed as a parameter to the log entry message. |
277 | * @since 1.44 |
278 | * @param array $groupChanges |
279 | * @param string $performerName |
280 | * @param string $targetName |
281 | * @param string $messagePrefix |
282 | * @return string |
283 | */ |
284 | protected function formatChangesToGroups( array $groupChanges, string $performerName, |
285 | string $targetName, string $messagePrefix = 'logentry-rights-rights-' |
286 | ) { |
287 | $formattedChanges = []; |
288 | |
289 | foreach ( $groupChanges as $changeType => $groups ) { |
290 | if ( !count( $groups ) ) { |
291 | continue; |
292 | } |
293 | |
294 | if ( $changeType === 'expiry-changed' ) { |
295 | $formattedList = $this->formatRightsListExpiryChanged( $groups ); |
296 | } else { |
297 | $formattedList = $this->formatRightsList( $groups ); |
298 | } |
299 | |
300 | $formattedChanges[] = $this->msg( |
301 | $messagePrefix . $changeType, |
302 | $formattedList, |
303 | $performerName, |
304 | $targetName |
305 | ); |
306 | } |
307 | |
308 | $uiLanguage = $this->context->getLanguage(); |
309 | return $uiLanguage->semicolonList( $formattedChanges ); |
310 | } |
311 | |
312 | private function formatRightsListExpiryChanged( array $groups ): string { |
313 | $list = []; |
314 | |
315 | foreach ( $groups as $group => [ $oldExpiry, $newExpiry ] ) { |
316 | $oldExpiryFormatted = $oldExpiry ? $this->formatDate( $oldExpiry ) : false; |
317 | $newExpiryFormatted = $newExpiry ? $this->formatDate( $newExpiry ) : false; |
318 | |
319 | if ( $oldExpiryFormatted && $newExpiryFormatted ) { |
320 | // The expiration was changed |
321 | $list[] = $this->msg( 'rightslogentry-expiry-changed' )->params( |
322 | $group, |
323 | $newExpiryFormatted['whole'], |
324 | $newExpiryFormatted['date'], |
325 | $newExpiryFormatted['time'], |
326 | $oldExpiryFormatted['whole'], |
327 | $oldExpiryFormatted['date'], |
328 | $oldExpiryFormatted['time'] |
329 | )->parse(); |
330 | } elseif ( $oldExpiryFormatted ) { |
331 | // The expiration was removed |
332 | $list[] = $this->msg( 'rightslogentry-expiry-removed' )->params( |
333 | $group, |
334 | $oldExpiryFormatted['whole'], |
335 | $oldExpiryFormatted['date'], |
336 | $oldExpiryFormatted['time'] |
337 | )->parse(); |
338 | } elseif ( $newExpiryFormatted ) { |
339 | // The expiration was added |
340 | $list[] = $this->msg( 'rightslogentry-expiry-set' )->params( |
341 | $group, |
342 | $newExpiryFormatted['whole'], |
343 | $newExpiryFormatted['date'], |
344 | $newExpiryFormatted['time'] |
345 | )->parse(); |
346 | } else { |
347 | // The rights are and were permanent |
348 | // Shouldn't happen as we process only changes to expiry time here |
349 | $list[] = htmlspecialchars( $group ); |
350 | } |
351 | } |
352 | |
353 | $uiLanguage = $this->context->getLanguage(); |
354 | return $uiLanguage->listToText( $list ); |
355 | } |
356 | |
357 | private function formatRightsList( array $groups ): string { |
358 | $uiLanguage = $this->context->getLanguage(); |
359 | // separate arrays of temporary and permanent memberships |
360 | $tempList = $permList = []; |
361 | |
362 | foreach ( $groups as $group => $expiry ) { |
363 | if ( $expiry ) { |
364 | // format the group and expiry into a friendly string |
365 | $expiryFormatted = $this->formatDate( $expiry ); |
366 | $tempList[] = $this->msg( 'rightslogentry-temporary-group' )->params( $group, |
367 | $expiryFormatted['whole'], $expiryFormatted['date'], $expiryFormatted['time'] ) |
368 | ->parse(); |
369 | } else { |
370 | // the right does not expire; just insert the group name |
371 | $permList[] = htmlspecialchars( $group ); |
372 | } |
373 | } |
374 | |
375 | // place all temporary memberships first, to avoid the ambiguity of |
376 | // "administrator, bureaucrat and importer (temporary, until X time)" |
377 | return $uiLanguage->listToText( array_merge( $tempList, $permList ) ); |
378 | } |
379 | |
380 | private function formatDate( string $date ): array { |
381 | $uiLanguage = $this->context->getLanguage(); |
382 | $uiUser = $this->context->getUser(); |
383 | |
384 | return [ |
385 | 'whole' => $uiLanguage->userTimeAndDate( $date, $uiUser ), |
386 | 'date' => $uiLanguage->userDate( $date, $uiUser ), |
387 | 'time' => $uiLanguage->userTime( $date, $uiUser ), |
388 | ]; |
389 | } |
390 | |
391 | protected function getParametersForApi() { |
392 | $entry = $this->entry; |
393 | $params = $entry->getParameters(); |
394 | |
395 | static $map = [ |
396 | '4:array:oldgroups', |
397 | '5:array:newgroups', |
398 | '4::oldgroups' => '4:array:oldgroups', |
399 | '5::newgroups' => '5:array:newgroups', |
400 | ]; |
401 | foreach ( $map as $index => $key ) { |
402 | if ( isset( $params[$index] ) ) { |
403 | $params[$key] = $params[$index]; |
404 | unset( $params[$index] ); |
405 | } |
406 | } |
407 | |
408 | // Really old entries do not have log params, so form them from whatever info |
409 | // we have. |
410 | // Also walk through the parallel arrays of groups and metadata, combining each |
411 | // metadata array with the name of the group it pertains to |
412 | if ( isset( $params['4:array:oldgroups'] ) ) { |
413 | $params['4:array:oldgroups'] = $this->makeGroupArray( $params['4:array:oldgroups'] ); |
414 | |
415 | $oldmetadata =& $params['oldmetadata']; |
416 | // unset old metadata entry to ensure metadata goes at the end of the params array |
417 | unset( $params['oldmetadata'] ); |
418 | $params['oldmetadata'] = array_map( static function ( $index ) use ( $params, $oldmetadata ) { |
419 | $result = [ 'group' => $params['4:array:oldgroups'][$index] ]; |
420 | if ( isset( $oldmetadata[$index] ) ) { |
421 | $result += $oldmetadata[$index]; |
422 | } |
423 | $result['expiry'] = ApiResult::formatExpiry( $result['expiry'] ?? null ); |
424 | |
425 | return $result; |
426 | }, array_keys( $params['4:array:oldgroups'] ) ); |
427 | } |
428 | |
429 | if ( isset( $params['5:array:newgroups'] ) ) { |
430 | $params['5:array:newgroups'] = $this->makeGroupArray( $params['5:array:newgroups'] ); |
431 | |
432 | $newmetadata =& $params['newmetadata']; |
433 | // unset old metadata entry to ensure metadata goes at the end of the params array |
434 | unset( $params['newmetadata'] ); |
435 | $params['newmetadata'] = array_map( static function ( $index ) use ( $params, $newmetadata ) { |
436 | $result = [ 'group' => $params['5:array:newgroups'][$index] ]; |
437 | if ( isset( $newmetadata[$index] ) ) { |
438 | $result += $newmetadata[$index]; |
439 | } |
440 | $result['expiry'] = ApiResult::formatExpiry( $result['expiry'] ?? null ); |
441 | |
442 | return $result; |
443 | }, array_keys( $params['5:array:newgroups'] ) ); |
444 | } |
445 | |
446 | return $params; |
447 | } |
448 | |
449 | public function formatParametersForApi() { |
450 | $ret = parent::formatParametersForApi(); |
451 | if ( isset( $ret['oldgroups'] ) ) { |
452 | ApiResult::setIndexedTagName( $ret['oldgroups'], 'g' ); |
453 | } |
454 | if ( isset( $ret['newgroups'] ) ) { |
455 | ApiResult::setIndexedTagName( $ret['newgroups'], 'g' ); |
456 | } |
457 | if ( isset( $ret['oldmetadata'] ) ) { |
458 | ApiResult::setArrayType( $ret['oldmetadata'], 'array' ); |
459 | ApiResult::setIndexedTagName( $ret['oldmetadata'], 'g' ); |
460 | } |
461 | if ( isset( $ret['newmetadata'] ) ) { |
462 | ApiResult::setArrayType( $ret['newmetadata'], 'array' ); |
463 | ApiResult::setIndexedTagName( $ret['newmetadata'], 'g' ); |
464 | } |
465 | return $ret; |
466 | } |
467 | |
468 | /** |
469 | * @param string|string[] $group |
470 | */ |
471 | private function makeGroupArray( $group ): array { |
472 | // Migrate old group params from string to array |
473 | if ( $group === '' ) { |
474 | $group = []; |
475 | } elseif ( is_string( $group ) ) { |
476 | $group = array_map( 'trim', explode( ',', $group ) ); |
477 | } |
478 | return $group; |
479 | } |
480 | } |
481 | |
482 | /** @deprecated class alias since 1.44 */ |
483 | class_alias( RightsLogFormatter::class, 'RightsLogFormatter' ); |