Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
67.04% covered (warning)
67.04%
120 / 179
22.22% covered (danger)
22.22%
2 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
67.04% covered (warning)
67.04%
120 / 179
22.22% covered (danger)
22.22%
2 / 9
148.83
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 getUserCounts
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 onSaveUserOptions
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
56
 onGetPreferences
80.51% covered (warning)
80.51%
95 / 118
0.00% covered (danger)
0.00%
0 / 1
41.06
 onPreferencesGetIcon
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onUserGetDefaultOptions
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 onMakeGlobalVariablesScript
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 onSkinTemplateNavigation__Universal
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 onExtensionTypes
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * This file is part of the MediaWiki extension BetaFeatures.
4 *
5 * BetaFeatures is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * BetaFeatures is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with BetaFeatures.  If not, see <http://www.gnu.org/licenses/>.
17 *
18 * BetaFeatures extension hooks
19 *
20 * @file
21 * @ingroup Extensions
22 * @copyright 2013 Mark Holmquist and others; see AUTHORS
23 * @license GPL-2.0-or-later
24 */
25
26// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
27
28namespace MediaWiki\Extension\BetaFeatures;
29
30use Exception;
31use MediaWiki\Config\Config;
32use MediaWiki\Context\RequestContext;
33use MediaWiki\Deferred\DeferredUpdates;
34use MediaWiki\Extension\BetaFeatures\Hooks\HookRunner;
35use MediaWiki\Hook\ExtensionTypesHook;
36use MediaWiki\Hook\PreferencesGetIconHook;
37use MediaWiki\Hook\SkinTemplateNavigation__UniversalHook;
38use MediaWiki\HookContainer\HookContainer;
39use MediaWiki\JobQueue\JobQueueGroupFactory;
40use MediaWiki\Output\Hook\MakeGlobalVariablesScriptHook;
41use MediaWiki\Output\OutputPage;
42use MediaWiki\Preferences\Hook\GetPreferencesHook;
43use MediaWiki\SpecialPage\SpecialPage;
44use MediaWiki\User\Hook\UserGetDefaultOptionsHook;
45use MediaWiki\User\Options\Hook\SaveUserOptionsHook;
46use MediaWiki\User\Options\UserOptionsManager;
47use MediaWiki\User\User;
48use MediaWiki\User\UserFactory;
49use MediaWiki\User\UserIdentity;
50use MediaWiki\User\UserIdentityUtils;
51use ObjectCacheFactory;
52use SkinFactory;
53use SkinTemplate;
54use Wikimedia\Rdbms\IConnectionProvider;
55
56class Hooks implements
57    ExtensionTypesHook,
58    GetPreferencesHook,
59    MakeGlobalVariablesScriptHook,
60    PreferencesGetIconHook,
61    SaveUserOptionsHook,
62    SkinTemplateNavigation__UniversalHook,
63    UserGetDefaultOptionsHook
64{
65
66    /**
67     * @var array An array of each of the available Beta Features, with their requirements, if any.
68     * It is passed client-side for JavaScript rendering/responsiveness.
69     */
70    private static $features = [];
71
72    private Config $config;
73    private IConnectionProvider $dbProvider;
74    private HookContainer $hookContainer;
75    private JobQueueGroupFactory $jobQueueGroupFactory;
76    private SkinFactory $skinFactory;
77    private UserFactory $userFactory;
78    private UserIdentityUtils $userIdentityUtils;
79    private UserOptionsManager $userOptionsManager;
80    private ObjectCacheFactory $objectCacheFactory;
81
82    public function __construct(
83        Config $config,
84        IConnectionProvider $dbProvider,
85        HookContainer $hookContainer,
86        JobQueueGroupFactory $jobQueueGroupFactory,
87        SkinFactory $skinFactory,
88        UserFactory $userFactory,
89        UserIdentityUtils $userIdentityUtils,
90        UserOptionsManager $userOptionsManager,
91        ObjectCacheFactory $objectCacheFactory
92    ) {
93        $this->config = $config;
94        $this->dbProvider = $dbProvider;
95        $this->hookContainer = $hookContainer;
96        $this->jobQueueGroupFactory = $jobQueueGroupFactory;
97        $this->skinFactory = $skinFactory;
98        $this->userFactory = $userFactory;
99        $this->userIdentityUtils = $userIdentityUtils;
100        $this->userOptionsManager = $userOptionsManager;
101        $this->objectCacheFactory = $objectCacheFactory;
102    }
103
104    /**
105     * @param string[] $prefs
106     * @param IConnectionProvider $dbProvider
107     * @return int[]
108     */
109    public static function getUserCounts( array $prefs, IConnectionProvider $dbProvider ) {
110        $counts = [];
111        if ( !$prefs ) {
112            return $counts;
113        }
114
115        $dbr = $dbProvider->getReplicaDatabase();
116        $res = $dbr->newSelectQueryBuilder()
117            ->select( [ 'feature', 'number' ] )
118            ->from( 'betafeatures_user_counts' )
119            ->where( [ 'feature' => $prefs ] )
120            ->caller( __METHOD__ )->fetchResultSet();
121
122        foreach ( $res as $row ) {
123            $counts[$row->feature] = $row->number;
124        }
125
126        return $counts;
127    }
128
129    /**
130     * @see https://www.mediawiki.org/wiki/Manual:Hooks/SaveUserOptions
131     *
132     * @param UserIdentity $user User who's just saved their preferences
133     * @param array &$modifiedOptions List of modified options
134     * @param array $originalOptions List of original user options
135     * @throws Exception
136     */
137    public function onSaveUserOptions(
138        UserIdentity $user,
139        array &$modifiedOptions,
140        array $originalOptions
141    ) {
142        if ( !$user->isRegistered() || $this->userIdentityUtils->isTemp( $user ) ) {
143            // Anonymous and temporary users do not have options, shorten out.
144            return;
145        }
146
147        $betaFeatures = $this->config->get( 'BetaFeatures' );
148        $user = $this->userFactory->newFromUserIdentity( $user );
149        ( new HookRunner( $this->hookContainer ) )->onGetBetaFeaturePreferences( $user, $betaFeatures );
150
151        $jobs = [];
152        foreach ( $betaFeatures as $name => $option ) {
153            if ( !array_key_exists( $name, $modifiedOptions ) ) {
154                continue;
155            }
156            $newVal = $modifiedOptions[$name];
157            $oldVal = $originalOptions[$name] ?? null;
158            // Check if this preference meaningfully changed
159            if ( $oldVal == $newVal ) {
160                // unchanged
161                continue;
162            }
163            // Enqueue a job to update the count for this preference
164            $jobs[] = new UpdateBetaFeatureUserCountsJob(
165                [ 'prefs' => [ $name ] ],
166                $this->dbProvider
167            );
168        }
169        if ( $jobs !== [] ) {
170            $this->jobQueueGroupFactory->makeJobQueueGroup()->push( $jobs );
171        }
172    }
173
174    /**
175     * @param User $user
176     * @param array[] &$prefs
177     * @throws BetaFeaturesMissingFieldException
178     */
179    public function onGetPreferences( $user, &$prefs ) {
180        $betaPrefs = $this->config->get( 'BetaFeatures' );
181        $depHooks = [];
182
183        $hookRunner = new HookRunner( $this->hookContainer );
184        $hookRunner->onGetBetaFeaturePreferences( $user, $betaPrefs );
185
186        // The following messages are generated upstream by the 'section' value
187        // * prefs-betafeatures
188        // * prefs-description-betafeatures
189        $count = count( $betaPrefs );
190        $prefs['betafeatures-section-desc'] = [
191            'type' => 'info',
192            'default' => static function () use ( $count ) {
193                return wfMessage( 'betafeatures-section-desc' )
194                    ->numParams( $count )
195                    ->parseAsBlock();
196            },
197            'section' => 'betafeatures',
198            'raw' => true,
199        ];
200
201        $prefs['betafeatures-auto-enroll'] = [
202            'type' => 'check',
203            'label-message' => 'betafeatures-auto-enroll',
204            'help-message' => 'betafeatures-auto-enroll-help',
205            'section' => 'betafeatures',
206        ];
207
208        // Purely visual field.
209        $prefs['betafeatures-breaking-hr'] = [
210            'class' => HTMLHorizontalRuleField::class,
211            'section' => 'betafeatures',
212        ];
213
214        $counts = self::getUserCounts( array_keys( $betaPrefs ), $this->dbProvider );
215
216        // Set up dependency hooks array
217        // This complex structure brought to you by Per-Wiki Configuration,
218        // coming soon to a wiki very near you.
219        $hookRunner->onGetBetaFeatureDependencyHooks( $depHooks );
220
221        $autoEnrollSaveSettings = [];
222        $autoEnrollAll = $this->userOptionsManager->getBoolOption( $user, 'betafeatures-auto-enroll' );
223
224        $autoEnroll = [];
225
226        foreach ( $betaPrefs as $key => $info ) {
227            if ( isset( $info['auto-enrollment'] ) ) {
228                $autoEnroll[$info['auto-enrollment']] = $key;
229            }
230        }
231
232        $hiddenPrefs = $this->config->get( 'HiddenPrefs' );
233        $allowlist = $this->config->get( 'BetaFeaturesAllowList' );
234
235        foreach ( $betaPrefs as $key => $info ) {
236            // Check if feature should be skipped
237            if (
238                // Check if feature is hidden
239                in_array( $key, $hiddenPrefs ) ||
240                // Check if feature is in the allow list
241                ( is_array( $allowlist ) && !in_array( $key, $allowlist ) ) ||
242                // Check if dependencies are set but not met
243                (
244                    isset( $info['dependent'] ) &&
245                    $info['dependent'] === true &&
246                    isset( $depHooks[$key] ) &&
247                    !$this->hookContainer->run( $depHooks[$key] )
248                )
249            ) {
250                continue;
251            }
252
253            $opt = [
254                'class' => HTMLFeatureField::class,
255                'section' => 'betafeatures',
256                'disable-if' => [ '===', 'betafeatures-auto-enroll', '1' ],
257            ];
258
259            $requiredFields = [
260                'label-message' => true,
261                'desc-message' => true,
262                'screenshot' => false,
263                'requirements' => false,
264                'info-link' => false,
265                'info-message' => false,
266                'discussion-link' => false,
267                'discussion-message' => false,
268                'disabled' => false,
269            ];
270
271            foreach ( $requiredFields as $field => $required ) {
272                if ( isset( $info[$field] ) ) {
273                    $opt[$field] = $info[$field];
274                } elseif ( $required ) {
275                    // A required field isn't present in the info array
276                    // we got from the GetBetaFeaturePreferences hook.
277                    // Don't add this feature to the form.
278                    throw new BetaFeaturesMissingFieldException(
279                        "The field {$field} was missing from the beta feature {$key}."
280                    );
281                }
282            }
283
284            if ( isset( $counts[$key] ) ) {
285                $opt['user-count'] = $counts[$key];
286            }
287
288            // Set the beta feature in the standard preferences array
289            // Just before, unset the key to resort it in the array, in the case the key was already set
290            unset( $prefs[$key] );
291            $prefs[$key] = $opt;
292
293            $autoEnrollForThisPref = false;
294
295            if ( isset( $info['group'] ) && isset( $autoEnroll[$info['group']] ) ) {
296                $autoEnrollForThisPref = $this->userOptionsManager
297                    ->getBoolOption( $user, $autoEnroll[$info['group']] );
298            }
299
300            $exemptAutoEnroll = ( $info['exempt-from-auto-enrollment'] ?? false )
301                || ( $info['disabled'] ?? false );
302            $autoEnrollHere = !$exemptAutoEnroll && ( $autoEnrollAll || $autoEnrollForThisPref );
303
304            // Use raw value for existence test
305            $currentValue = $this->userOptionsManager->getOption( $user, $key );
306
307            // Keep it break now... The tests applied are against the comments below.
308            // Fixing all the tests is not worthwhile, the auto-enroll logic should be refactored later.
309            if ( $autoEnrollHere && $currentValue !== '1' ) {
310                // We haven't seen this before, and the user has auto-enroll enabled!
311                // Set the option to true and make it visible for the current user object
312                $this->userOptionsManager->setOption( $user, $key, true );
313                // Also put it aside for saving the settings later
314                $autoEnrollSaveSettings[$key] = true;
315            }
316
317            self::$features[$key] = [];
318            self::$features[$key]['__skip-auto-enroll'] = $exemptAutoEnroll;
319        }
320
321        foreach ( $betaPrefs as $key => $info ) {
322            if ( isset( $prefs[$key]['requirements'] ) ) {
323                // Check which other beta features are required, and fetch their labels
324                if ( isset( $prefs[$key]['requirements']['betafeatures'] ) ) {
325                    $requiredPrefs = [];
326                    foreach ( $prefs[$key]['requirements']['betafeatures'] as $preference ) {
327                        if ( !$this->userOptionsManager->getBoolOption( $user, $preference ) ) {
328                            $requiredPrefs[] = $prefs[$preference]['label-message'];
329                        }
330                    }
331                    if ( count( $requiredPrefs ) ) {
332                        $prefs[$key]['requirements']['betafeatures-messages'] = $requiredPrefs;
333                    }
334                }
335
336                // Test skin support
337                if ( isset( $prefs[$key]['requirements']['skins'] ) ) {
338                    // Remove any skins that aren't installed or users can't choose
339                    $prefs[$key]['requirements']['skins'] = array_intersect(
340                        /** @phan-suppress-next-line PhanTypeInvalidDimOffset,PhanTypeMismatchArgumentInternal */
341                        $prefs[$key]['requirements']['skins'],
342                        array_keys( $this->skinFactory->getAllowedSkins() )
343                    );
344
345                    if ( empty( $prefs[$key]['requirements']['skins'] ) ) {
346                        // If there are no valid skins, don't show the preference
347                        wfDebugLog( 'BetaFeatures', "The $key BetaFeature has no valid skins installed." );
348                        continue;
349                    }
350                    // Also check if the user's current skin is supported
351                    $prefs[$key]['requirements']['skin-not-supported'] = !in_array(
352                        RequestContext::getMain()->getSkin()->getSkinName(),
353                        $prefs[$key]['requirements']['skins']
354                    );
355                }
356            }
357
358            // If a unsupported browsers list is supplied, store so it can be passed as JSON
359            self::$features[$key]['unsupportedList'] = $prefs[$key]['requirements']['unsupportedList'] ?? null;
360        }
361
362        if ( $autoEnrollSaveSettings !== [] ) {
363            // Save the preferences to the DB post-send
364            DeferredUpdates::addCallableUpdate(
365                function () use ( $user, $autoEnrollSaveSettings ) {
366                    $cache = $this->objectCacheFactory->getLocalClusterInstance();
367                    $key = $cache->makeKey( __CLASS__, 'prefs-update', $user->getId() );
368                    // T95839: If concurrent requests pile on (e.g. multiple tabs), only let one
369                    // thread bother doing these updates. This avoids pointless error log spam.
370                    if ( $cache->lock( $key, 0, $cache::TTL_MINUTE ) ) {
371                        // Refresh, because the settings could be changed in the meantime by api or special page
372                        $userLatest = $user->getInstanceForUpdate();
373                        // Apply the settings and save
374                        foreach ( $autoEnrollSaveSettings as $key => $option ) {
375                            $this->userOptionsManager->setOption( $userLatest, $key, $option );
376                        }
377                        $this->userOptionsManager->saveOptions( $userLatest );
378                        $cache->unlock( $key );
379                    }
380                }
381            );
382        }
383    }
384
385    /**
386     * Add icon for Special:Preferences mobile layout
387     *
388     * @param array &$iconNames Array of icon names for their respective sections.
389     */
390    public function onPreferencesGetIcon( &$iconNames ) {
391        $iconNames[ 'betafeatures' ] = 'labFlask';
392    }
393
394    /**
395     * Add default preferences values
396     *
397     * @param array &$defaultOptions Array of preference keys and their default values.
398     */
399    public function onUserGetDefaultOptions( &$defaultOptions ) {
400        $betaPrefs = $this->config->get( 'BetaFeatures' );
401
402        foreach ( $betaPrefs as $key => $_ ) {
403            $defaultOptions[$key] = false;
404        }
405    }
406
407    /**
408     * @param array &$vars
409     * @param OutputPage $out
410     */
411    public function onMakeGlobalVariablesScript( &$vars, $out ): void {
412        if ( self::$features ) {
413            // This is added to page view HTML on all articles.
414            // FIXME: Move this to the preferences page somehow, or
415            // bundle with the module that loads betafeatures.js.
416            $vars['wgBetaFeaturesFeatures'] = self::$features;
417        }
418    }
419
420    /**
421     * @param SkinTemplate $skintemplate
422     * @param array[] &$links
423     */
424    public function onSkinTemplateNavigation__Universal(
425        $skintemplate,
426        &$links
427    ): void {
428        $user = $skintemplate->getUser();
429        if ( $user->isNamed() ) {
430            $personalUrls = $links['user-menu'] ?? [];
431            $personalUrls = wfArrayInsertAfter( $personalUrls, [
432                // The following messages are generated upstream
433                // * tooltip-pt-betafeatures
434                'betafeatures' => [
435                    'text' => wfMessage( 'betafeatures-toplink' )->text(),
436                    'href' => SpecialPage::getTitleFor(
437                        'Preferences', false, 'mw-prefsection-betafeatures'
438                    )->getLinkURL(),
439                    'active' => $skintemplate->getTitle()->isSpecial( 'Preferences' ),
440                    'icon' => 'labFlask'
441                ],
442            ], 'preferences' );
443            $links['user-menu'] = $personalUrls;
444        }
445    }
446
447    /**
448     * @param string[] &$extTypes
449     */
450    public function onExtensionTypes( &$extTypes ) {
451        $extTypes['betafeatures'] = wfMessage( 'betafeatures-extension-type' )->text();
452    }
453
454}