Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
90 / 90 |
|
100.00% |
8 / 8 |
CRAP | |
100.00% |
1 / 1 |
UserAgentClientHintsFormatter | |
100.00% |
90 / 90 |
|
100.00% |
8 / 8 |
41 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
generateMsgCache | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
2 | |||
batchFormatClientHintsData | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
formatClientHintsDataObject | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
7 | |||
listToTextWithoutAnd | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
combineClientHintsData | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
12 | |||
generateClientHintsListItem | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
8 | |||
getBrandAsString | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
7 |
1 | <?php |
2 | |
3 | namespace MediaWiki\CheckUser\Services; |
4 | |
5 | use MediaWiki\CheckUser\ClientHints\ClientHintsBatchFormatterResults; |
6 | use MediaWiki\CheckUser\ClientHints\ClientHintsData; |
7 | use MediaWiki\CheckUser\ClientHints\ClientHintsLookupResults; |
8 | use MediaWiki\Config\ServiceOptions; |
9 | use MessageLocalizer; |
10 | |
11 | /** |
12 | * A service that formats ClientHintsData objects into a human-readable |
13 | * string format. |
14 | */ |
15 | class 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 | } |