Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
102 / 102 |
|
100.00% |
5 / 5 |
CRAP | |
100.00% |
1 / 1 |
ClientHintsData | |
100.00% |
102 / 102 |
|
100.00% |
5 / 5 |
22 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
newFromJsApi | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 | |||
newFromDatabaseRows | |
100.00% |
31 / 31 |
|
100.00% |
1 / 1 |
6 | |||
toDatabaseRows | |
100.00% |
34 / 34 |
|
100.00% |
1 / 1 |
13 | |||
jsonSerialize | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
1 |
1 | <?php |
2 | |
3 | namespace MediaWiki\CheckUser\ClientHints; |
4 | |
5 | use JsonSerializable; |
6 | use MediaWiki\CheckUser\Services\UserAgentClientHintsManager; |
7 | use MediaWiki\Logger\LoggerFactory; |
8 | use TypeError; |
9 | |
10 | /** |
11 | * Value object for modeling user agent client hints data. |
12 | */ |
13 | class ClientHintsData implements JsonSerializable { |
14 | public const HEADER_TO_CLIENT_HINTS_DATA_PROPERTY_NAME = [ |
15 | "Sec-CH-UA" => "userAgent", |
16 | "Sec-CH-UA-Arch" => "architecture", |
17 | "Sec-CH-UA-Bitness" => "bitness", |
18 | "Sec-CH-UA-Form-Factor" => "formFactor", |
19 | "Sec-CH-UA-Full-Version-List" => "fullVersionList", |
20 | "Sec-CH-UA-Mobile" => "mobile", |
21 | "Sec-CH-UA-Model" => "model", |
22 | "Sec-CH-UA-Platform" => "platform", |
23 | "Sec-CH-UA-Platform-Version" => "platformVersion", |
24 | "Sec-CH-UA-WoW64" => "woW64" |
25 | ]; |
26 | |
27 | private ?string $architecture; |
28 | private ?string $bitness; |
29 | private ?array $brands; |
30 | private ?string $formFactor; |
31 | private ?array $fullVersionList; |
32 | private ?bool $mobile; |
33 | private ?string $model; |
34 | private ?string $platform; |
35 | private ?string $platformVersion; |
36 | private ?string $userAgent; |
37 | private ?bool $woW64; |
38 | |
39 | /** |
40 | * @param string|null $architecture |
41 | * @param string|null $bitness |
42 | * @param string[][]|null $brands |
43 | * @param string|null $formFactor |
44 | * @param string[][]|null $fullVersionList |
45 | * @param bool|null $mobile |
46 | * @param string|null $model |
47 | * @param string|null $platform |
48 | * @param string|null $platformVersion |
49 | * @param string|null $userAgent |
50 | * @param bool|null $woW64 |
51 | */ |
52 | public function __construct( |
53 | ?string $architecture, |
54 | ?string $bitness, |
55 | ?array $brands, |
56 | ?string $formFactor, |
57 | ?array $fullVersionList, |
58 | ?bool $mobile, |
59 | ?string $model, |
60 | ?string $platform, |
61 | ?string $platformVersion, |
62 | ?string $userAgent, |
63 | ?bool $woW64 |
64 | ) { |
65 | $this->architecture = $architecture; |
66 | $this->bitness = $bitness; |
67 | $this->brands = $brands; |
68 | $this->formFactor = $formFactor; |
69 | $this->fullVersionList = $fullVersionList; |
70 | $this->mobile = $mobile; |
71 | $this->model = $model; |
72 | $this->platform = $platform; |
73 | $this->platformVersion = $platformVersion; |
74 | $this->userAgent = $userAgent; |
75 | $this->woW64 = $woW64; |
76 | } |
77 | |
78 | /** |
79 | * Given an array of data received from the client-side JavaScript API for obtaining |
80 | * user agent client hints, construct a new ClientHintsData object. |
81 | * |
82 | * @see UserAgentClientHintsManager::getBodyValidator |
83 | * |
84 | * @param array $data |
85 | * @return ClientHintsData |
86 | * @throws TypeError on invalid data (such as platformVersion being an array). |
87 | */ |
88 | public static function newFromJsApi( array $data ): ClientHintsData { |
89 | return new self( |
90 | $data['architecture'] ?? null, |
91 | $data['bitness'] ?? null, |
92 | $data['brands'] ?? null, |
93 | null, |
94 | $data['fullVersionList'] ?? null, |
95 | $data['mobile'] ?? null, |
96 | $data['model'] ?? null, |
97 | $data['platform'] ?? null, |
98 | $data['platformVersion'] ?? null, |
99 | null, |
100 | null |
101 | ); |
102 | } |
103 | |
104 | /** |
105 | * Given an array of rows from the useragent_clienthints table, |
106 | * construct a new ClientHintsData object. |
107 | * |
108 | * @param array $rows |
109 | * @return ClientHintsData |
110 | */ |
111 | public static function newFromDatabaseRows( array $rows ): ClientHintsData { |
112 | $data = []; |
113 | foreach ( $rows as $row ) { |
114 | if ( in_array( $row['uach_name'], [ 'brands', 'fullVersionList' ] ) ) { |
115 | // There can be multiple client hint values with this name |
116 | // for brands and fullVersionList |
117 | if ( !array_key_exists( $row['uach_name'], $data ) ) { |
118 | $data[$row['uach_name']] = []; |
119 | } |
120 | // Assume that last space separates version number from brand name (e.g. "NotABrand 123") |
121 | // When saving to the DB, we combine the version number and brand name |
122 | // with a separator of a space. |
123 | $explodedValue = explode( ' ', $row['uach_value'] ); |
124 | if ( count( $explodedValue ) > 1 ) { |
125 | $versionNumber = array_pop( $explodedValue ); |
126 | $brandName = implode( ' ', $explodedValue ); |
127 | $data[$row['uach_name']][] = [ |
128 | "brand" => $brandName, |
129 | "version" => $versionNumber |
130 | ]; |
131 | } else { |
132 | // No space was found, therefore keep the value as is. |
133 | $data[$row['uach_name']][] = $row['uach_value']; |
134 | } |
135 | } else { |
136 | $value = $row['uach_value']; |
137 | // Convert "0" and "1" to their boolean values |
138 | // for "mobile" and "woW64" |
139 | if ( in_array( $row['uach_name'], [ 'mobile', 'woW64' ] ) ) { |
140 | $value = boolval( $value ); |
141 | } |
142 | $data[$row['uach_name']] = $value; |
143 | } |
144 | } |
145 | return new ClientHintsData( |
146 | $data['architecture'] ?? null, |
147 | $data['bitness'] ?? null, |
148 | $data['brands'] ?? null, |
149 | $data['formFactor'] ?? null, |
150 | $data['fullVersionList'] ?? null, |
151 | $data['mobile'] ?? null, |
152 | $data['model'] ?? null, |
153 | $data['platform'] ?? null, |
154 | $data['platformVersion'] ?? null, |
155 | $data['userAgent'] ?? null, |
156 | $data['woW64'] ?? null |
157 | ); |
158 | } |
159 | |
160 | /** |
161 | * @return array[] |
162 | * An array of arrays containing maps of uach_name => uach_value items |
163 | * to insert into the cu_useragent_clienthints table. |
164 | */ |
165 | public function toDatabaseRows(): array { |
166 | $rows = []; |
167 | foreach ( $this->jsonSerialize() as $key => $value ) { |
168 | if ( !is_array( $value ) ) { |
169 | if ( $value === "" || $value === null ) { |
170 | continue; |
171 | } |
172 | if ( is_bool( $value ) ) { |
173 | $value = $value ? "1" : "0"; |
174 | } |
175 | $value = trim( $value ); |
176 | $rows[] = [ 'uach_name' => $key, 'uach_value' => $value ]; |
177 | } else { |
178 | // Some values are arrays, for example: |
179 | // [ |
180 | // "brand": "Not.A/Brand", |
181 | // "version": "8" |
182 | // ], |
183 | // We transform these by joining brand/version with a space, e.g. "Not.A/Brand 8" |
184 | $itemsAsString = []; |
185 | foreach ( $value as $item ) { |
186 | if ( is_array( $item ) ) { |
187 | // Sort so "brand" is always first and then "version". |
188 | ksort( $item ); |
189 | // Trim the data to remove leading and trailing spaces. |
190 | $item = array_map( static function ( $value ) { |
191 | return trim( $value ); |
192 | }, $item ); |
193 | // Convert arrays to a string by imploding |
194 | $itemsAsString[] = implode( ' ', $item ); |
195 | } elseif ( is_string( $item ) || is_numeric( $item ) ) { |
196 | // Allow integers, floats and strings to be stored |
197 | // as their string representation. |
198 | // |
199 | // Trim the data to remove leading and trailing spaces. |
200 | $item = strval( $item ); |
201 | $itemsAsString[] = trim( $item ); |
202 | } |
203 | } |
204 | // Remove any duplicates |
205 | $itemsAsString = array_unique( $itemsAsString ); |
206 | // Limit to 10 maximum items |
207 | if ( count( $itemsAsString ) > 10 ) { |
208 | LoggerFactory::getInstance( 'CheckUser' )->info( |
209 | "ClientHintsData object has too many items in array for {key}. " . |
210 | "Truncated to 10 items.", |
211 | [ $key ] |
212 | ); |
213 | // array_splice modifies the array in place, by taking the array |
214 | // as the first argument via reference. The return value is |
215 | // the elements that were "extracted", which in this case are |
216 | // the items to be ignored. |
217 | array_splice( $itemsAsString, 10 ); |
218 | } |
219 | // Now convert to DB rows |
220 | foreach ( $itemsAsString as $item ) { |
221 | $rows[] = [ |
222 | 'uach_name' => $key, |
223 | 'uach_value' => $item |
224 | ]; |
225 | } |
226 | } |
227 | } |
228 | return $rows; |
229 | } |
230 | |
231 | /** @inheritDoc */ |
232 | public function jsonSerialize(): array { |
233 | return [ |
234 | 'architecture' => $this->architecture, |
235 | 'bitness' => $this->bitness, |
236 | 'brands' => $this->brands, |
237 | 'formFactor' => $this->formFactor, |
238 | 'fullVersionList' => $this->fullVersionList, |
239 | 'mobile' => $this->mobile, |
240 | 'model' => $this->model, |
241 | 'platform' => $this->platform, |
242 | 'platformVersion' => $this->platformVersion, |
243 | 'userAgent' => $this->userAgent, |
244 | 'woW64' => $this->woW64, |
245 | ]; |
246 | } |
247 | } |