Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
90 / 90
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
1 / 1
UserAgentClientHintsFormatter
100.00% covered (success)
100.00%
90 / 90
100.00% covered (success)
100.00%
8 / 8
41
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 generateMsgCache
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 batchFormatClientHintsData
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 formatClientHintsDataObject
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 listToTextWithoutAnd
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 combineClientHintsData
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
12
 generateClientHintsListItem
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
8
 getBrandAsString
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2
3namespace MediaWiki\CheckUser\Services;
4
5use MediaWiki\CheckUser\ClientHints\ClientHintsBatchFormatterResults;
6use MediaWiki\CheckUser\ClientHints\ClientHintsData;
7use MediaWiki\CheckUser\ClientHints\ClientHintsLookupResults;
8use MediaWiki\Config\ServiceOptions;
9use MessageLocalizer;
10
11/**
12 * A service that formats ClientHintsData objects into a human-readable
13 * string format.
14 */
15class UserAgentClientHintsFormatter {
16
17    public const CONSTRUCTOR_OPTIONS = [
18        'CheckUserClientHintsForDisplay',
19        'CheckUserClientHintsValuesToHide'
20    ];
21
22    public const NAME_TO_MESSAGE_KEY = [
23        "userAgent" => "checkuser-clienthints-name-brand",
24        "architecture" => "checkuser-clienthints-name-architecture",
25        "bitness" => "checkuser-clienthints-name-bitness",
26        "brands" => "checkuser-clienthints-name-brand",
27        "formFactor" => "checkuser-clienthints-name-form-factor",
28        "fullVersionList" => "checkuser-clienthints-name-brand",
29        "mobile" => "checkuser-clienthints-name-mobile",
30        "model" => "checkuser-clienthints-name-model",
31        "platform" => "checkuser-clienthints-name-platform",
32        "platformVersion" => "checkuser-clienthints-name-platform-version",
33        "woW64" => "checkuser-clienthints-name-wow64"
34    ];
35
36    private MessageLocalizer $messageLocalizer;
37    private ServiceOptions $options;
38
39    private array $msgCache;
40
41    /**
42     * @param MessageLocalizer $messageLocalizer
43     * @param ServiceOptions $options
44     */
45    public function __construct(
46        MessageLocalizer $messageLocalizer,
47        ServiceOptions $options
48    ) {
49        $this->messageLocalizer = $messageLocalizer;
50        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
51        $this->options = $options;
52        $this->generateMsgCache();
53    }
54
55    /**
56     * Generates a cache of messages that are used that also take no
57     * parameters so that they do not need to be re-calculated each time.
58     *
59     * @return void
60     */
61    private function generateMsgCache(): void {
62        foreach ( self::NAME_TO_MESSAGE_KEY as $msg ) {
63            $this->msgCache[$msg] = $this->messageLocalizer->msg( $msg )->escaped();
64        }
65        $this->msgCache['checkuser-clienthints-value-yes'] = $this->messageLocalizer
66            ->msg( 'checkuser-clienthints-value-yes' )->escaped();
67        $this->msgCache['checkuser-clienthints-value-no'] = $this->messageLocalizer
68            ->msg( 'checkuser-clienthints-value-no' )->escaped();
69    }
70
71    /**
72     * Batch formats ClientHintsData objects associated with reference IDs by
73     * taking the result from UserAgentClientHintsLookup::getClientHintsByReferenceIds
74     * and converting the ClientHintsData objects into human-readable strings.
75     *
76     * @param ClientHintsLookupResults $clientHintsLookupResults
77     * @return ClientHintsBatchFormatterResults
78     */
79    public function batchFormatClientHintsData(
80        ClientHintsLookupResults $clientHintsLookupResults
81    ): ClientHintsBatchFormatterResults {
82        [ $referenceIdsToClientHintsDataIndex, $clientHintsDataObjects ] = $clientHintsLookupResults->getRawData();
83        foreach ( $clientHintsDataObjects as &$clientHintsDataObject ) {
84            // This "batches" the formatting as it only does it once per unique combination of
85            // ClientHintsData object instead of passing the same object to ::formatClientHintsDataObject
86            // multiple times.
87            $clientHintsDataObject = $this->formatClientHintsDataObject( $clientHintsDataObject );
88        }
89        return new ClientHintsBatchFormatterResults( $referenceIdsToClientHintsDataIndex, $clientHintsDataObjects );
90    }
91
92    /**
93     * @param ClientHintsData $clientHintsData
94     * @return string
95     */
96    public function formatClientHintsDataObject( ClientHintsData $clientHintsData ): string {
97        $clientHintsForDisplay = $this->options->get( 'CheckUserClientHintsForDisplay' );
98        // Combine Client Hints data where possible to reduce the length of the generated string.
99        $dataAsArray = $this->combineClientHintsData( $clientHintsData->jsonSerialize(), $clientHintsForDisplay );
100        // Get all the Client Hints data as their human-readable string representations
101        // in an array for later combination.
102        $dataAsStringArray = [];
103        foreach ( $clientHintsForDisplay as $clientHintName ) {
104            if ( array_key_exists( $clientHintName, $dataAsArray ) ) {
105                // If the Client Hint name is configured for display and is set in the $dataAsArray array
106                // of Client Hints data, then add it to the $dataAsStringArray after conversion to a string.
107                if ( in_array( $clientHintName, [ 'brands', 'fullVersionList' ] ) ) {
108                    // If the Client Hint name is 'brands' or 'fullVersionList', then the value will be
109                    // an array of items. Therefore add each brand as a new item to the $dataAsStringArray array.
110                    if ( $dataAsArray[$clientHintName] !== null ) {
111                        foreach ( $dataAsArray[$clientHintName] as $key => $brand ) {
112                            // Get the brand as string using ::getBrandAsString, and if it returns a string
113                            // that isn't empty then add it to $dataAsStringArray
114                            $brandAsString = $this->getBrandAsString( $brand, false );
115                            if ( $brandAsString ) {
116                                $dataAsStringArray[$clientHintName . '-' . $key] = $this->generateClientHintsListItem(
117                                    $clientHintName, $brandAsString
118                                );
119                            }
120                        }
121                    }
122                } else {
123                    $dataAsStringArray[$clientHintName] = $this->generateClientHintsListItem(
124                        $clientHintName, $dataAsArray[$clientHintName]
125                    );
126                }
127            }
128        }
129        // Remove items that equate to false (in this case this should be empty strings).
130        $dataAsStringArray = array_filter( $dataAsStringArray );
131        return $this->listToTextWithoutAnd( $dataAsStringArray );
132    }
133
134    /**
135     * Functionally similar to Language::listToText, but
136     * does not separate the last items with an "and" and instead
137     * uses another comma.
138     *
139     * This is done as the Language::listToText adding the "and"
140     * message makes the separation between the second to last
141     * Client Hints value and the last Client Hints name unclear.
142     *
143     * @param string[] $list
144     * @param-taint $list tainted
145     * @return string
146     */
147    private function listToTextWithoutAnd( array $list ): string {
148        $itemCount = count( $list );
149        if ( $itemCount < 1 ) {
150            return '';
151        }
152        $comma = $this->messageLocalizer->msg( 'comma-separator' )->escaped();
153        return implode( $comma, $list );
154    }
155
156    /**
157     * Combines the Client Hints data given in the $dataAsArray parameter
158     * that is being configured to be displayed by $clientHintsForDisplay:
159     *  * The "platform" and "platformVersion" items are combined into "platform" if
160     *    both are to be displayed and both have values.
161     *  * Items in the "brands" array are removed if an item exists in the "fullVersionList"
162     *    array that has the same brand name and significant version number.
163     *
164     * @param array $dataAsArray The Client Hints data from ClientHintsData::jsonSerialize.
165     * @param array &$clientHintsForDisplay The value of the 'CheckUserClientHintsForDisplay' in a
166     *   variable that can be modified without modifying that config. This is passed by reference.
167     * @return array
168     */
169    private function combineClientHintsData( array $dataAsArray, array &$clientHintsForDisplay ): array {
170        if (
171            in_array( 'platform', $clientHintsForDisplay ) &&
172            in_array( 'platformVersion', $clientHintsForDisplay ) &&
173            ( $dataAsArray['platform'] ?? false ) &&
174            ( $dataAsArray['platformVersion'] ?? false )
175        ) {
176            // Combine "platform" and "platformVersion" if both are set and are configured to be displayed.
177            // When combining them use a hardcoded space so be consistent with "brands" and "fullVersionList".
178            $dataAsArray["platform"] = $dataAsArray["platform"] . ' ' . $dataAsArray["platformVersion"];
179            unset( $dataAsArray["platformVersion"] );
180            // Update $clientHintsForDisplay to be have "platform" to the position of "platformVersion" if
181            // that was ordered closer to the start of $clientHintsForDisplay
182            $platformKey = array_search( 'platform', $clientHintsForDisplay );
183            $platformVersionKey = array_search( 'platformVersion', $clientHintsForDisplay );
184            if ( $platformVersionKey < $platformKey ) {
185                // Move "platform" via array_splice calls to be the item before "platformVersion" if
186                // "platformVersion" has a smaller integer key.
187                array_splice( $clientHintsForDisplay, $platformKey, 1 );
188                array_splice( $clientHintsForDisplay, $platformVersionKey, 0, 'platform' );
189            }
190        }
191        // Remove "brands" items if a entry for that brand name also exists in "fullVersionList"
192        // and both "brands" and "fullVersionList" are configured for display.
193        if (
194            in_array( 'brands', $clientHintsForDisplay ) &&
195            in_array( 'fullVersionList', $clientHintsForDisplay ) &&
196            ( $dataAsArray['brands'] ?? false ) &&
197            ( $dataAsArray['fullVersionList'] ?? false )
198        ) {
199            // Get the items in 'brands' as strings for comparison
200            $brandsAsString = array_map( function ( $item ) {
201                return $this->getBrandAsString( $item, false );
202            }, $dataAsArray['brands'] );
203            // Remove brands that were not parsable.
204            $brandsAsString = array_filter( $brandsAsString );
205            foreach ( $dataAsArray["fullVersionList"] as $fullVersionBrand ) {
206                // If the 'fullVersionList' brand name with only the significant version number
207                // exactly matches an item in the 'brands' array, then remove that item in the
208                // 'brands' array as it duplicates the one in 'fullVersionList'.
209                $fullVersionBrandWithOnlySignificantVersion = $this->getBrandAsString( $fullVersionBrand, true );
210                $matchingBrandKey = array_search( $fullVersionBrandWithOnlySignificantVersion, $brandsAsString );
211                if ( $matchingBrandKey !== false ) {
212                    unset( $dataAsArray['brands'][$matchingBrandKey] );
213                    unset( $brandsAsString[$matchingBrandKey] );
214                }
215            }
216            // Reset the key numbering after some values may have been unset
217            // above.
218            $dataAsArray['brands'] = array_values( $dataAsArray['brands'] );
219        }
220        return $dataAsArray;
221    }
222
223    /**
224     * Generates a string item for the Client Hints string list returned by
225     * ::formatClientHintsDataObject. This adds the translated name and the
226     * value using the 'checkuser-clienthints-list-item' message.
227     *
228     * This method does not check if the $clientHintName is configured for
229     * display, but does check if the $clientHintValue is to be hidden.
230     *
231     * @param string $clientHintName The name of the Client Hints name-value pair, where the name is one of the
232     *   array keys returned by ClientHintsData::jsonSerialize
233     * @param string|bool|null $clientHintValue The value for the Client Hints name-value pair. If this is a boolean,
234     *   then "true" and "false" are converted to the messages "checkuser-clienthints-value-yes" and
235     *   "checkuser-clienthints-value-no" respectively. If this is null, then an empty string is returned.
236     * @return string
237     */
238    private function generateClientHintsListItem( string $clientHintName, $clientHintValue ) {
239        // Return the empty string for a null value or an falsey string value.
240        if (
241            $clientHintValue === null ||
242            ( is_string( $clientHintValue ) && !$clientHintValue )
243        ) {
244            return '';
245        }
246        $clientHintsValuesToHide = $this->options->get( 'CheckUserClientHintsValuesToHide' );
247        // Return an empty string if the item is configured to be hidden.
248        if (
249            array_key_exists( $clientHintName, $clientHintsValuesToHide ) &&
250            in_array( $clientHintValue, $clientHintsValuesToHide[$clientHintName] )
251        ) {
252            return '';
253        }
254        // If the item is a boolean, convert the value to the translated version of
255        // "Yes" for true and "No" for false.
256        if ( is_bool( $clientHintValue ) ) {
257            if ( $clientHintValue ) {
258                $clientHintValue = $this->msgCache['checkuser-clienthints-value-yes'];
259            } else {
260                $clientHintValue = $this->msgCache['checkuser-clienthints-value-no'];
261            }
262        } else {
263            // If the item is a string, then trim leading and ending spaces
264            // as some wikis may have uach_value items with trailing or leading spaces.
265            // This would not be necessary if T345837 is implemented. This is currently
266            // needed because of untrimmed uach_value items in the database as detailed
267            // in T345649.
268            $clientHintValue = trim( $clientHintValue );
269        }
270        // Return the Client Hints name-value pair using the "checkuser-clienthints-list-item" message
271        // to combine the name and value.
272        return $this->messageLocalizer->msg( 'checkuser-clienthints-list-item' )
273            ->rawParams( $this->msgCache[self::NAME_TO_MESSAGE_KEY[$clientHintName]] )
274            ->params( $clientHintValue )
275            ->escaped();
276    }
277
278    /**
279     * Gets the string version of an array or string in the 'brands' and 'fullVersionList' arrays.
280     * With the version optionally cut to only the significant number (the number before the first dot).
281     *
282     * @param mixed $item An array item from the 'brands' or 'fullVersionList' array.
283     * @param bool $significantOnly If an array, then attempt to make the version number only have
284     *   the significant number.
285     * @return string|null If $item is an array then the imploded array. if $item is string then $item. Otherwise null.
286     */
287    private function getBrandAsString( $item, bool $significantOnly ): ?string {
288        if ( is_array( $item ) ) {
289            ksort( $item );
290            if ( $significantOnly && count( $item ) > 1 ) {
291                // Remove the non-significant numbers from the version number if $significantOnly is set.
292                if ( array_key_exists( 'version', $item ) ) {
293                    // If the 'version' key is set, then use this.
294                    if ( strpos( $item['version'], '.' ) ) {
295                        // If there is a point, then remove all text after the . in the 'version'.
296                        $item['version'] = substr( $item['version'], 0, strpos( $item['version'], '.' ) );
297                    }
298                }
299            }
300            // Implode the array into a string to get the brand as a string.
301            // The code above will have handled the removal of non-significant
302            // version numbers if this was requested and was also possible.
303            return implode( ' ', $item );
304        } elseif ( is_string( $item ) ) {
305            return $item;
306        }
307        // If the item is neither a string or array, then
308        // just return null as there isn't going to be a
309        // useful way to display the brand as a string in this case.
310        return null;
311    }
312}