Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
55.36% covered (warning)
55.36%
62 / 112
26.67% covered (danger)
26.67%
4 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
55.36% covered (warning)
55.36%
62 / 112
26.67% covered (danger)
26.67%
4 / 15
262.62
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 onPageSaveComplete
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 onPageDeleteComplete
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 onUserGetDefaultOptions
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 onGetPreferences
80.00% covered (warning)
80.00%
28 / 35
0.00% covered (danger)
0.00%
0 / 1
10.80
 onPreferencesGetLegend
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 onPreferencesGetIcon
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onResourceLoaderRegisterModules
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 onBeforePageDisplay
67.86% covered (warning)
67.86%
19 / 28
0.00% covered (danger)
0.00%
0 / 1
18.61
 makeLegacyWarning
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 onEditFilterMergedContent
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 onContentHandlerDefaultModelFor
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
2.50
 onWgQueryPages
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onDeleteUnknownPreferences
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 onGetUserPermissionsErrors
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * Copyright © 2007 Daniel Kinzler
4 *
5 * This program 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 * This program 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 along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23namespace MediaWiki\Extension\Gadgets;
24
25use ApiMessage;
26use Content;
27use Exception;
28use HTMLForm;
29use IContextSource;
30use InvalidArgumentException;
31use ManualLogEntry;
32use MediaWiki\Extension\Gadgets\Content\GadgetDefinitionContent;
33use MediaWiki\Extension\Gadgets\Special\SpecialGadgetUsage;
34use MediaWiki\Hook\BeforePageDisplayHook;
35use MediaWiki\Hook\DeleteUnknownPreferencesHook;
36use MediaWiki\Hook\EditFilterMergedContentHook;
37use MediaWiki\Hook\PreferencesGetIconHook;
38use MediaWiki\Hook\PreferencesGetLegendHook;
39use MediaWiki\Html\Html;
40use MediaWiki\Output\OutputPage;
41use MediaWiki\Page\Hook\PageDeleteCompleteHook;
42use MediaWiki\Page\ProperPageIdentity;
43use MediaWiki\Permissions\Authority;
44use MediaWiki\Permissions\Hook\GetUserPermissionsErrorsHook;
45use MediaWiki\Preferences\Hook\GetPreferencesHook;
46use MediaWiki\ResourceLoader\Hook\ResourceLoaderRegisterModulesHook;
47use MediaWiki\ResourceLoader\ResourceLoader;
48use MediaWiki\Revision\Hook\ContentHandlerDefaultModelForHook;
49use MediaWiki\Revision\RevisionRecord;
50use MediaWiki\SpecialPage\Hook\WgQueryPagesHook;
51use MediaWiki\SpecialPage\SpecialPage;
52use MediaWiki\Status\Status;
53use MediaWiki\Storage\Hook\PageSaveCompleteHook;
54use MediaWiki\Title\Title;
55use MediaWiki\Title\TitleValue;
56use MediaWiki\User\Hook\UserGetDefaultOptionsHook;
57use MediaWiki\User\Options\UserOptionsLookup;
58use MediaWiki\User\User;
59use MessageSpecifier;
60use OOUI\HtmlSnippet;
61use RequestContext;
62use Skin;
63use Wikimedia\Rdbms\IExpression;
64use Wikimedia\Rdbms\IReadableDatabase;
65use Wikimedia\Rdbms\LikeValue;
66use Wikimedia\WrappedString;
67use WikiPage;
68use Xml;
69
70class Hooks implements
71    PageDeleteCompleteHook,
72    PageSaveCompleteHook,
73    UserGetDefaultOptionsHook,
74    GetPreferencesHook,
75    PreferencesGetIconHook,
76    PreferencesGetLegendHook,
77    ResourceLoaderRegisterModulesHook,
78    BeforePageDisplayHook,
79    EditFilterMergedContentHook,
80    ContentHandlerDefaultModelForHook,
81    WgQueryPagesHook,
82    DeleteUnknownPreferencesHook,
83    GetUserPermissionsErrorsHook
84{
85    private GadgetRepo $gadgetRepo;
86    private UserOptionsLookup $userOptionsLookup;
87
88    public function __construct(
89        GadgetRepo $gadgetRepo,
90        UserOptionsLookup $userOptionsLookup
91    ) {
92        $this->gadgetRepo = $gadgetRepo;
93        $this->userOptionsLookup = $userOptionsLookup;
94    }
95
96    /**
97     * Handle MediaWiki\Page\Hook\PageSaveCompleteHook
98     *
99     * @param WikiPage $wikiPage
100     * @param mixed $userIdentity unused
101     * @param string $summary
102     * @param int $flags
103     * @param mixed $revisionRecord unused
104     * @param mixed $editResult unused
105     */
106    public function onPageSaveComplete(
107        $wikiPage,
108        $userIdentity,
109        $summary,
110        $flags,
111        $revisionRecord,
112        $editResult
113    ): void {
114        $title = $wikiPage->getTitle();
115        $this->gadgetRepo->handlePageUpdate( $title );
116    }
117
118    /**
119     * Handle MediaWiki\Page\Hook\PageDeleteCompleteHook
120     *
121     * @param ProperPageIdentity $page
122     * @param Authority $deleter
123     * @param string $reason
124     * @param int $pageID
125     * @param RevisionRecord $deletedRev Last revision
126     * @param ManualLogEntry $logEntry
127     * @param int $archivedRevisionCount Number of revisions deleted
128     */
129    public function onPageDeleteComplete(
130        ProperPageIdentity $page,
131        Authority $deleter,
132        string $reason,
133        int $pageID,
134        RevisionRecord $deletedRev,
135        ManualLogEntry $logEntry,
136        int $archivedRevisionCount
137    ): void {
138        $title = TitleValue::newFromPage( $page );
139        $this->gadgetRepo->handlePageUpdate( $title );
140    }
141
142    /**
143     * UserGetDefaultOptions hook handler
144     * @param array &$defaultOptions Array of default preference keys and values
145     */
146    public function onUserGetDefaultOptions( &$defaultOptions ) {
147        $gadgets = $this->gadgetRepo->getStructuredList();
148        if ( !$gadgets ) {
149            return;
150        }
151
152        /**
153         * @var $gadget Gadget
154         */
155        foreach ( $gadgets as $thisSection ) {
156            foreach ( $thisSection as $gadgetId => $gadget ) {
157                // Hidden gadgets don't need to be added here, T299071
158                if ( !$gadget->isHidden() ) {
159                    $defaultOptions['gadget-' . $gadgetId] = $gadget->isOnByDefault() ? 1 : 0;
160                }
161            }
162        }
163    }
164
165    /**
166     * GetPreferences hook handler.
167     * @param User $user
168     * @param array &$preferences Preference descriptions
169     */
170    public function onGetPreferences( $user, &$preferences ) {
171        $gadgets = $this->gadgetRepo->getStructuredList();
172        if ( !$gadgets ) {
173            return;
174        }
175
176        $preferences['gadgets-intro'] = [
177            'type' => 'info',
178            'default' => wfMessage( 'gadgets-prefstext' )->parseAsBlock(),
179            'section' => 'gadgets',
180            'raw' => true,
181        ];
182
183        $safeMode = $this->userOptionsLookup->getOption( $user, 'forcesafemode' );
184        if ( $safeMode ) {
185            $preferences['gadgets-safemode'] = [
186                'type' => 'info',
187                'default' => Html::warningBox( wfMessage( 'gadgets-prefstext-safemode' )->parse() ),
188                'section' => 'gadgets',
189                'raw' => true,
190            ];
191        }
192
193        $skin = RequestContext::getMain()->getSkin();
194        foreach ( $gadgets as $section => $thisSection ) {
195            foreach ( $thisSection as $gadget ) {
196                // Only show option to enable gadget if it can be enabled
197                $type = 'api';
198                if (
199                    !$safeMode
200                    && !$gadget->isHidden()
201                    && $gadget->isAllowed( $user )
202                    && $gadget->isSkinSupported( $skin )
203                ) {
204                    $type = 'check';
205                }
206                $gname = $gadget->getName();
207                $sectionLabelMsg = "gadget-section-$section";
208
209                $preferences["gadget-$gname"] = [
210                    'type' => $type,
211                    'label-message' => $gadget->getDescriptionMessageKey(),
212                    'section' => $section !== '' ? "gadgets/$sectionLabelMsg" : 'gadgets',
213                    'default' => $gadget->isEnabled( $user ),
214                    'noglobal' => true,
215                ];
216            }
217        }
218    }
219
220    /**
221     * PreferencesGetLegend hook handler.
222     *
223     * Used to override the subsection heading labels for the gadget groups. The default message would
224     * be "prefs-$key", but we've previously used different messages, and they have on-wiki overrides
225     * that would have to be moved if the message keys changed.
226     *
227     * @param HTMLForm $form the HTMLForm object. This is a ContextSource as well
228     * @param string $key the section name
229     * @param string &$legend the legend text. Defaults to wfMessage( "prefs-$key" )->text() but may
230     *   be overridden
231     * @return bool|void True or no return value to continue or false to abort
232     */
233    public function onPreferencesGetLegend( $form, $key, &$legend ) {
234        if ( str_starts_with( $key, 'gadget-section-' ) ) {
235            $legend = new HtmlSnippet( $form->msg( $key )->parse() );
236        }
237    }
238
239    /**
240     * Add icon for Special:Preferences mobile layout
241     *
242     * @param array &$iconNames Array of icon names for their respective sections.
243     */
244    public function onPreferencesGetIcon( &$iconNames ) {
245        $iconNames[ 'gadgets' ] = 'puzzle';
246    }
247
248    /**
249     * ResourceLoaderRegisterModules hook handler.
250     * @param ResourceLoader $resourceLoader
251     */
252    public function onResourceLoaderRegisterModules( ResourceLoader $resourceLoader ): void {
253        foreach ( $this->gadgetRepo->getGadgetIds() as $id ) {
254            $resourceLoader->register( Gadget::getModuleName( $id ), [
255                'class' => GadgetResourceLoaderModule::class,
256                'id' => $id,
257            ] );
258        }
259    }
260
261    /**
262     * BeforePageDisplay hook handler.
263     * @param OutputPage $out
264     * @param Skin $skin
265     */
266    public function onBeforePageDisplay( $out, $skin ): void {
267        $repo = $this->gadgetRepo;
268        $ids = $repo->getGadgetIds();
269        if ( !$ids ) {
270            return;
271        }
272
273        $enabledLegacyGadgets = [];
274        $conditions = new GadgetLoadConditions( $out );
275
276        /**
277         * @var $gadget Gadget
278         */
279        foreach ( $ids as $id ) {
280            try {
281                $gadget = $repo->getGadget( $id );
282            } catch ( InvalidArgumentException $e ) {
283                continue;
284            }
285
286            if ( $conditions->check( $gadget ) ) {
287                if ( $gadget->hasModule() ) {
288                    if ( $gadget->getType() === 'styles' ) {
289                        $out->addModuleStyles( Gadget::getModuleName( $gadget->getName() ) );
290                    } else {
291                        $out->addModules( Gadget::getModuleName( $gadget->getName() ) );
292
293                        $peers = [];
294                        foreach ( $gadget->getPeers() as $peerName ) {
295                            try {
296                                $peers[] = $repo->getGadget( $peerName );
297                            } catch ( InvalidArgumentException $e ) {
298                                // Ignore, warning is emitted on Special:Gadgets
299                            }
300                        }
301                        // Load peer modules
302                        foreach ( $peers as $peer ) {
303                            if ( $peer->getType() === 'styles' ) {
304                                $out->addModuleStyles( Gadget::getModuleName( $peer->getName() ) );
305                            }
306                            // Else, if not type=styles: Use dependencies instead.
307                            // Note: No need for recursion as styles modules don't support
308                            // either of 'dependencies' and 'peers'.
309                        }
310                    }
311                }
312
313                if ( $gadget->getLegacyScripts() ) {
314                    $enabledLegacyGadgets[] = $id;
315                }
316            }
317        }
318
319        $strings = [];
320        foreach ( $enabledLegacyGadgets as $id ) {
321            $strings[] = $this->makeLegacyWarning( $id );
322        }
323        $out->addHTML( WrappedString::join( "\n", $strings ) );
324    }
325
326    /**
327     * @param string $id
328     * @return string|WrappedString HTML
329     */
330    private function makeLegacyWarning( $id ) {
331        $special = SpecialPage::getTitleFor( 'Gadgets' );
332
333        return ResourceLoader::makeInlineScript(
334            Xml::encodeJsCall( 'mw.log.warn', [
335                "Gadget \"$id\" was not loaded. Please migrate it to use ResourceLoader. " .
336                'See <' . $special->getCanonicalURL() . '>.'
337            ] )
338        );
339    }
340
341    /**
342     * Valid gadget definition page after content is modified
343     *
344     * @param IContextSource $context
345     * @param Content $content
346     * @param Status $status
347     * @param string $summary
348     * @param User $user
349     * @param bool $minoredit
350     * @throws Exception
351     * @return bool
352     */
353    public function onEditFilterMergedContent(
354        IContextSource $context,
355        Content $content,
356        Status $status,
357        $summary,
358        User $user,
359        $minoredit
360    ) {
361        if ( $content instanceof GadgetDefinitionContent ) {
362            $validateStatus = $content->validate();
363            if ( !$validateStatus->isGood() ) {
364                $status->merge( $validateStatus );
365                return false;
366            }
367        }
368
369        return true;
370    }
371
372    /**
373     * Create "MediaWiki:Gadgets/<id>.json" pages with GadgetDefinitionContent
374     *
375     * @param Title $title
376     * @param string &$model
377     * @return bool
378     */
379    public function onContentHandlerDefaultModelFor( $title, &$model ) {
380        if ( MediaWikiGadgetsJsonRepo::isGadgetDefinitionTitle( $title ) ) {
381            $model = 'GadgetDefinition';
382            return false;
383        }
384
385        return true;
386    }
387
388    /**
389     * Add the GadgetUsage special page to the list of QueryPages.
390     * @param array &$queryPages
391     */
392    public function onWgQueryPages( &$queryPages ) {
393        $queryPages[] = [ SpecialGadgetUsage::class, 'GadgetUsage' ];
394    }
395
396    /**
397     * Prevent gadget preferences from being deleted.
398     * @link https://www.mediawiki.org/wiki/Manual:Hooks/DeleteUnknownPreferences
399     * @param string[] &$where Array of where clause conditions to add to.
400     * @param IReadableDatabase $db
401     */
402    public function onDeleteUnknownPreferences( &$where, $db ) {
403        $where[] = $db->expr(
404            'up_property',
405            IExpression::NOT_LIKE,
406            new LikeValue( 'gadget-', $db->anyString() )
407        );
408    }
409
410    /**
411     * @param Title $title Title being checked against
412     * @param User $user Current user
413     * @param string $action Action being checked
414     * @param array|string|MessageSpecifier &$result User permissions error to add. If none, return true.
415     *   For consistency, error messages should be plain text with no special coloring,
416     *   bolding, etc. to show that they're errors; presenting them properly to the
417     *   user as errors is done by the caller.
418     * @return bool|void
419     */
420    public function onGetUserPermissionsErrors( $title, $user, $action, &$result ) {
421        if ( $action === 'edit'
422            && MediaWikiGadgetsJsonRepo::isGadgetDefinitionTitle( $title )
423        ) {
424            if ( !$user->isAllowed( 'editsitejs' ) ) {
425                $result = ApiMessage::create( wfMessage( 'sitejsprotected' ), 'sitejsprotected' );
426                return false;
427            }
428        }
429    }
430}