Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
102 / 102
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
ClientHintsData
100.00% covered (success)
100.00%
102 / 102
100.00% covered (success)
100.00%
5 / 5
22
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 newFromJsApi
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 newFromDatabaseRows
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
6
 toDatabaseRows
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
13
 jsonSerialize
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace MediaWiki\CheckUser\ClientHints;
4
5use JsonSerializable;
6use MediaWiki\CheckUser\Services\UserAgentClientHintsManager;
7use MediaWiki\Logger\LoggerFactory;
8use TypeError;
9
10/**
11 * Value object for modeling user agent client hints data.
12 */
13class 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}