Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
91.49% |
43 / 47 |
|
50.00% |
2 / 4 |
CRAP | |
0.00% |
0 / 1 |
VectorPrefDiffInstrumentation | |
91.49% |
43 / 47 |
|
50.00% |
2 / 4 |
16.16 | |
0.00% |
0 / 1 |
onPreferencesFormPreSave | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
generateSkinVersionName | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
generateSkinVersionNameFromSeparateSkins | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
createEventIfNecessary | |
97.37% |
37 / 38 |
|
0.00% |
0 / 1 |
10 |
1 | <?php |
2 | |
3 | namespace WikimediaEvents; |
4 | |
5 | use HTMLForm; |
6 | use MediaWiki\Extension\EventLogging\EventLogging; |
7 | use MediaWiki\Extension\EventLogging\Libs\UserBucketProvider\UserBucketProvider; |
8 | use MediaWiki\MediaWikiServices; |
9 | use MediaWiki\Preferences\Hook\PreferencesFormPreSaveHook; |
10 | use MediaWiki\User\User; |
11 | use MWCryptHash; |
12 | |
13 | // T261842: The Web team is interested in all skin changes involving Vector |
14 | // legacy and Vector latest. |
15 | class VectorPrefDiffInstrumentation implements PreferencesFormPreSaveHook { |
16 | /** |
17 | * EventLogging schema to use. |
18 | * @var string |
19 | */ |
20 | private const SCHEMA = '/analytics/pref_diff/1.0.0'; |
21 | |
22 | /** |
23 | * This must match the name used in $wgEventStreams config. |
24 | * @var string |
25 | */ |
26 | private const STREAM_NAME = 'mediawiki.skin_diff'; |
27 | |
28 | /** |
29 | * Keep in sync with Vector Constants::SKIN_NAME_LEGACY. |
30 | * @var string |
31 | */ |
32 | private const VECTOR_SKIN_NAME = 'vector'; |
33 | |
34 | /** |
35 | * Keep in sync with Vector Constants::SKIN_NAME_MODERN. |
36 | * @var string |
37 | */ |
38 | private const VECTOR_SKIN_NAME_MODERN = 'vector-2022'; |
39 | |
40 | /** |
41 | * Keep in sync with Vector Constants::PREF_KEY_SKIN_VERSION. |
42 | * @var string |
43 | */ |
44 | private const PREF_KEY_SKIN_VERSION = 'VectorSkinVersion'; |
45 | |
46 | /** |
47 | * Config key with a string value that is used as a salt to hash the user id. |
48 | * This should be set in `wmf-config/PrivateSettings`. |
49 | * @var string |
50 | */ |
51 | private const SALT_CONFIG_KEY = 'WMEVectorPrefDiffSalt'; |
52 | |
53 | /** |
54 | * Maps the Preferences form checkbox state (a key of `0` means an unchecked |
55 | * checkbox; a key of `1` means a checked checkbox) to the Vector skin |
56 | * version. |
57 | * |
58 | * Keep in sync with Vector HTMLLegacySkinVersionField::loadDataFromRequest. |
59 | * @var array |
60 | */ |
61 | private const CHECKBOX_TO_SKIN_VERSION_MAP = [ |
62 | 1 => '1', |
63 | 0 => '2' |
64 | ]; |
65 | |
66 | /** |
67 | * Maps skin names to vector version names. |
68 | * @var array |
69 | */ |
70 | private const SEPARATE_SKINS_TO_SKIN_VERSION_NAME_MAP = [ |
71 | self::VECTOR_SKIN_NAME => 'vector1', |
72 | self::VECTOR_SKIN_NAME_MODERN => 'vector2' |
73 | ]; |
74 | |
75 | /** |
76 | * Hook executed on user's Special:Preferences form save. |
77 | * |
78 | * @param array $formData Form data submitted by user |
79 | * @param HTMLForm $form A preferences form |
80 | * @param User $user Logged-in user |
81 | * @param bool &$result Variable defining is form save successful |
82 | * @param array $oldPreferences |
83 | */ |
84 | public function onPreferencesFormPreSave( |
85 | $formData, |
86 | $form, |
87 | $user, |
88 | &$result, |
89 | $oldPreferences |
90 | ) { |
91 | $event = self::createEventIfNecessary( $formData, $form, $user ); |
92 | |
93 | if ( is_array( $event ) ) { |
94 | EventLogging::submit( self::STREAM_NAME, $event ); |
95 | } |
96 | } |
97 | |
98 | /** |
99 | * Helper method that returns a more meaningful skin name given the value of |
100 | * Vector's skin version. If skin is not Vector, simply return $skin. |
101 | * |
102 | * @param string $skin |
103 | * @param string|bool $vectorSkinVersion Version of Vector skin in string |
104 | * form (e.g. '1' or '2') or bool form (e.g. true or false). |
105 | * @return string |
106 | */ |
107 | private static function generateSkinVersionName( $skin, $vectorSkinVersion ): string { |
108 | // The value of `$vectorSkinVersion` can either be a string or a bool |
109 | // depending on whether the field's `getDefault` method or |
110 | // `loadDataFromRequest` method is called. [1] The `getDefault` method can |
111 | // be called if the field is disabled which can occur through the |
112 | // GlobalPreferences extension. [2] Therefore, we must check whether the |
113 | // value is a string or a bool to get the correct skin version. |
114 | // |
115 | // Please see T261842#7084144 for additional context. |
116 | // |
117 | // [1] https://github.com/wikimedia/mediawiki/blob/fca9c972de9333bfbf881fdaa639abe27b9de4da/includes/htmlform/HTMLForm.php#L1861-L1865 |
118 | // [2] https://github.com/wikimedia/mediawiki-extensions-GlobalPreferences/blob/ccf4c9d470bfc0714119153b592bcc167dceccc6/includes/GlobalPreferencesFactory.php#L175 |
119 | if ( is_bool( $vectorSkinVersion ) ) { |
120 | // Since this value is a bool, we must map it to the corresponding skin |
121 | // version to get a meaningful skin version. |
122 | $vectorSkinVersion = self::CHECKBOX_TO_SKIN_VERSION_MAP[ |
123 | (int)$vectorSkinVersion |
124 | ]; |
125 | } |
126 | |
127 | return $skin === self::VECTOR_SKIN_NAME ? $skin . $vectorSkinVersion : $skin; |
128 | } |
129 | |
130 | /** |
131 | * Helper method that converts the legacy or modern Vector skin names (e.g. |
132 | * 'vector-2022') into skin version names (e.g. 'vector2'). Returns $skin if |
133 | * $skin is not an interation of Vector. |
134 | * @param string $skin |
135 | * @return string |
136 | */ |
137 | private static function generateSkinVersionNameFromSeparateSkins( $skin ): string { |
138 | return self::SEPARATE_SKINS_TO_SKIN_VERSION_NAME_MAP[ $skin ] ?? $skin; |
139 | } |
140 | |
141 | /** |
142 | * Creates an EventLogging event if a skin changes has been made that |
143 | * involves Vector legacy/latest and returns null otherwise. |
144 | * |
145 | * @param array $formData Form data submitted by user |
146 | * @param HTMLForm $form A preferences form |
147 | * @param User $user Logged-in user |
148 | * |
149 | * @return array|null An event array or null if an event cannot be |
150 | * produced. |
151 | */ |
152 | private static function createEventIfNecessary( |
153 | array $formData, |
154 | HTMLForm $form, |
155 | User $user |
156 | ): ?array { |
157 | $salt = MediaWikiServices::getInstance()->getMainConfig()->get( self::SALT_CONFIG_KEY ); |
158 | // Exit early if preconditions aren't met. |
159 | if ( !( |
160 | $form->hasField( 'skin' ) && |
161 | $salt !== null |
162 | ) ) { |
163 | return null; |
164 | } |
165 | |
166 | // T291098: Check if 'vector-2022 is an option. |
167 | $hasVectorSkinNameModern = in_array( self::VECTOR_SKIN_NAME_MODERN, $form->getField( 'skin' )->getOptions() ); |
168 | |
169 | if ( !$hasVectorSkinNameModern && !$form->hasField( self::PREF_KEY_SKIN_VERSION ) ) { |
170 | return null; |
171 | } |
172 | |
173 | // Get the old skin value from the form's default value. |
174 | $oldSkin = (string)$form->getField( 'skin' )->getDefault(); |
175 | $oldSkinVersionName = $hasVectorSkinNameModern ? |
176 | self::generateSkinVersionNameFromSeparateSkins( $oldSkin ) : |
177 | self::generateSkinVersionName( |
178 | $oldSkin, |
179 | $form->getField( self::PREF_KEY_SKIN_VERSION )->getDefault() |
180 | ); |
181 | // Get the new skin value from the form data that was submitted. |
182 | $newSkin = $formData['skin'] ?? ''; |
183 | $newSkinVersionName = $hasVectorSkinNameModern ? |
184 | self::generateSkinVersionNameFromSeparateSkins( $newSkin ) : |
185 | self::generateSkinVersionName( |
186 | $newSkin, |
187 | $formData[ self::PREF_KEY_SKIN_VERSION ] ?? '' |
188 | ); |
189 | |
190 | $involvesVector = |
191 | in_array( 'vector1', [ $oldSkinVersionName, $newSkinVersionName ], true ) || |
192 | in_array( 'vector2', [ $oldSkinVersionName, $newSkinVersionName ], true ); |
193 | |
194 | // We are only interested in skin changes that involve Vector. |
195 | if ( |
196 | $involvesVector && |
197 | $oldSkinVersionName !== $newSkinVersionName |
198 | ) { |
199 | |
200 | return [ |
201 | '$schema' => self::SCHEMA, |
202 | // Generate a unique deterministic hash using a salt. Don't send the |
203 | // bare user id for privacy reasons. |
204 | 'user_hash' => MWCryptHash::hmac( |
205 | (string)$user->getId(), |
206 | $salt, |
207 | false |
208 | ), |
209 | 'initial_state' => $oldSkinVersionName, |
210 | 'final_state' => $newSkinVersionName, |
211 | 'bucketed_user_edit_count' => UserBucketProvider::getUserEditCountBucket( $user ), |
212 | ]; |
213 | } |
214 | |
215 | return null; |
216 | } |
217 | } |