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