Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.04% covered (success)
92.04%
185 / 201
68.75% covered (warning)
68.75%
11 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
RightsLogFormatter
92.50% covered (success)
92.50%
185 / 200
68.75% covered (warning)
68.75%
11 / 16
61.52
0.00% covered (danger)
0.00%
0 / 1
 makePageLink
42.11% covered (danger)
42.11%
8 / 19
0.00% covered (danger)
0.00%
0 / 1
9.85
 getMessageKey
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getMessageParameters
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
5
 shouldProcessParams
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getOldGroups
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getNewGroups
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 joinGroupsWithExpiries
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 replaceGroupsWithMemberNames
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 classifyGroupChanges
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
3
 formatChangesToGroups
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 formatRightsListExpiryChanged
96.77% covered (success)
96.77%
30 / 31
0.00% covered (danger)
0.00%
0 / 1
8
 formatRightsList
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 formatDate
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 getParametersForApi
100.00% covered (success)
100.00%
35 / 35
100.00% covered (success)
100.00%
1 / 1
7
 formatParametersForApi
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 makeGroupArray
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
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
26namespace MediaWiki\Logging;
27
28use MediaWiki\Api\ApiResult;
29use MediaWiki\MainConfigNames;
30use MediaWiki\Message\Message;
31use MediaWiki\Title\Title;
32use 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 */
40class 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 */
483class_alias( RightsLogFormatter::class, 'RightsLogFormatter' );