Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.49% covered (success)
91.49%
43 / 47
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
VectorPrefDiffInstrumentation
91.49% covered (success)
91.49%
43 / 47
50.00% covered (danger)
50.00%
2 / 4
16.16
0.00% covered (danger)
0.00%
0 / 1
 onPreferencesFormPreSave
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 generateSkinVersionName
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 generateSkinVersionNameFromSeparateSkins
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 createEventIfNecessary
97.37% covered (success)
97.37%
37 / 38
0.00% covered (danger)
0.00%
0 / 1
10
1<?php
2
3namespace WikimediaEvents;
4
5use HTMLForm;
6use MediaWiki\Extension\EventLogging\EventLogging;
7use MediaWiki\Extension\EventLogging\Libs\UserBucketProvider\UserBucketProvider;
8use MediaWiki\MediaWikiServices;
9use MediaWiki\Preferences\Hook\PreferencesFormPreSaveHook;
10use MediaWiki\User\User;
11use MWCryptHash;
12
13// T261842: The Web team is interested in all skin changes involving Vector
14// legacy and Vector latest.
15class 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}