Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.38% covered (success)
96.38%
133 / 138
70.00% covered (warning)
70.00%
7 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
MediaWikiGadgetsDefinitionRepo
96.38% covered (success)
96.38%
133 / 138
70.00% covered (warning)
70.00%
7 / 10
44
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getGadget
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 getGadgetIds
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handlePageUpdate
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 purgeDefinitionCache
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 makeDefinitionCacheKey
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 loadGadgets
95.65% covered (success)
95.65%
22 / 23
0.00% covered (danger)
0.00%
0 / 1
4
 fetchStructuredList
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 listFromDefinition
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 newFromDefinition
96.00% covered (success)
96.00%
72 / 75
0.00% covered (danger)
0.00%
0 / 1
22
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\Extension\Gadgets;
22
23use InvalidArgumentException;
24use MediaWiki\Content\TextContent;
25use MediaWiki\Linker\LinkTarget;
26use MediaWiki\MediaWikiServices;
27use MediaWiki\Revision\RevisionLookup;
28use MediaWiki\Revision\SlotRecord;
29use MediaWiki\Title\Title;
30use Wikimedia\ObjectCache\BagOStuff;
31use Wikimedia\ObjectCache\WANObjectCache;
32use Wikimedia\Rdbms\Database;
33use Wikimedia\Rdbms\IConnectionProvider;
34
35/**
36 * Gadgets repo powered by MediaWiki:Gadgets-definition
37 */
38class MediaWikiGadgetsDefinitionRepo extends GadgetRepo {
39    private const CACHE_VERSION = 4;
40
41    /** @var array|null */
42    private $definitions;
43
44    public function __construct(
45        private readonly IConnectionProvider $dbProvider,
46        private readonly WANObjectCache $wanCache,
47        private readonly RevisionLookup $revLookup,
48        private readonly BagOStuff $srvCache,
49    ) {
50    }
51
52    /**
53     * @param string $id
54     * @throws InvalidArgumentException
55     * @return Gadget
56     */
57    public function getGadget( string $id ): Gadget {
58        $gadgets = $this->loadGadgets();
59        if ( !isset( $gadgets[$id] ) ) {
60            throw new InvalidArgumentException( "No gadget registered for '$id'" );
61        }
62
63        return new Gadget( $gadgets[$id] );
64    }
65
66    public function getGadgetIds(): array {
67        return array_keys( $this->loadGadgets() );
68    }
69
70    public function handlePageUpdate( LinkTarget $target ): void {
71        if ( $target->inNamespace( NS_MEDIAWIKI ) && $target->getDBkey() === 'Gadgets-definition' ) {
72            $this->purgeDefinitionCache();
73        }
74    }
75
76    /**
77     * Purge the definitions cache, for example when MediaWiki:Gadgets-definition is edited.
78     */
79    private function purgeDefinitionCache(): void {
80        $key = $this->makeDefinitionCacheKey( $this->wanCache );
81
82        $this->wanCache->delete( $key );
83        $this->srvCache->delete( $key );
84        $this->definitions = null;
85    }
86
87    /**
88     * @param WANObjectCache $cache
89     * @return string
90     */
91    private function makeDefinitionCacheKey( WANObjectCache $cache ) {
92        return $cache->makeKey(
93            'gadgets-definition',
94            Gadget::GADGET_CLASS_VERSION,
95            self::CACHE_VERSION
96        );
97    }
98
99    /**
100     * Get list of gadgets.
101     *
102     * @return array[] List of Gadget objects
103     */
104    protected function loadGadgets(): array {
105        if ( defined( 'MW_PHPUNIT_TEST' ) && MediaWikiServices::getInstance()->isStorageDisabled() ) {
106            // Bail out immediately if storage is disabled. This should never happen in normal operations, but can
107            // happen a lot in tests: this method is called from the UserGetDefaultOptions hook handler, so any test
108            // that uses UserOptionsLookup will end up reaching this code, which is problematic if the test is not
109            // in the Database group (T155147).
110            return [];
111        }
112        // From back to front:
113        //
114        // 3. wan cache (e.g. memcached)
115        //    This improves end-user latency and reduces database load.
116        //    It is purged when the data changes.
117        //
118        // 2. server cache (e.g. APCu).
119        //    Very short blind TTL, mainly to avoid high memcached I/O.
120        //
121        // 1. process cache. Faster repeat calls.
122        if ( $this->definitions === null ) {
123            $key = $this->makeDefinitionCacheKey( $this->wanCache );
124            $this->definitions = $this->srvCache->getWithSetCallback(
125                $key,
126                // between 7 and 15 seconds to avoid memcached/lockTSE stampede (T203786)
127                mt_rand( 7, 15 ),
128                function () use ( $key ) {
129                    return $this->wanCache->getWithSetCallback(
130                        $key,
131                        // 1 day
132                        Gadget::CACHE_TTL,
133                        function ( $old, &$ttl, &$setOpts ) {
134                            // Reduce caching of known-stale data (T157210)
135                            $setOpts += Database::getCacheSetOptions( $this->dbProvider->getReplicaDatabase() );
136
137                            return $this->fetchStructuredList();
138                        },
139                        [
140                            'version' => 2,
141                            // Avoid database stampede
142                            'lockTSE' => 300,
143                         ]
144                    );
145                }
146            );
147        }
148        return $this->definitions;
149    }
150
151    /**
152     * Fetch list of gadgets and returns it as associative array of sections with gadgets
153     * e.g. [ $name => $gadget1, etc. ]
154     * @return array[]
155     */
156    public function fetchStructuredList() {
157        // T157210: avoid using wfMessage() to avoid staleness due to cache layering
158        $title = Title::makeTitle( NS_MEDIAWIKI, 'Gadgets-definition' );
159        $revRecord = $this->revLookup->getRevisionByTitle( $title );
160        if ( !$revRecord
161            || !$revRecord->getContent( SlotRecord::MAIN )
162            || $revRecord->getContent( SlotRecord::MAIN )->isEmpty()
163        ) {
164            return [];
165        }
166
167        $content = $revRecord->getContent( SlotRecord::MAIN );
168        $g = ( $content instanceof TextContent ) ? $content->getText() : '';
169
170        $gadgets = $this->listFromDefinition( $g );
171
172        wfDebug( __METHOD__ . ": MediaWiki:Gadgets-definition parsed, cache entry should be updated\n" );
173
174        return $gadgets;
175    }
176
177    /**
178     * Generates a structured list of Gadget objects from a definition
179     *
180     * @param string $definition
181     * @return array[] List of Gadget objects indexed by the gadget's name.
182     */
183    private function listFromDefinition( $definition ): array {
184        $definition = preg_replace( '/<!--.*?-->/s', '', $definition );
185        $lines = preg_split( '/(\r\n|\r|\n)+/', $definition );
186
187        $gadgets = [];
188        $section = '';
189
190        foreach ( $lines as $line ) {
191            $m = [];
192            if ( preg_match( '/^==+ *([^*:\s|]+)\s*(?<!=)==+\s*$/', $line, $m ) ) {
193                $section = $m[1];
194            } else {
195                $gadget = $this->newFromDefinition( $line, $section );
196                if ( $gadget ) {
197                    $gadgets[$gadget->getName()] = $gadget->toArray();
198                }
199            }
200        }
201
202        return $gadgets;
203    }
204
205    /**
206     * Creates an instance of this class from definition in MediaWiki:Gadgets-definition
207     * @param string $definition Gadget definition
208     * @param string $section
209     * @return Gadget|false Instance of Gadget class or false if $definition is invalid
210     */
211    public function newFromDefinition( $definition, $section ) {
212        if ( !preg_match(
213            '/^\*+ *([a-zA-Z](?:[-_:.\w ]*[a-zA-Z0-9])?)(\s*\[.*?\])?\s*((\|[^|]*)+)\s*$/',
214            $definition,
215            $matches
216        ) ) {
217            return false;
218        }
219        [ , $name, $options, $pages ] = $matches;
220        $options = trim( $options, ' []' );
221
222        // NOTE: the gadget name is used as part of the name of a form field,
223        // and must follow the rules defined in https://www.w3.org/TR/html4/types.html#type-cdata
224        // Also, title-normalization applies.
225        $name = str_replace( ' ', '_', $name );
226        // If the name is too long, then RL will throw an exception when
227        // we try to register the module
228        if ( !Gadget::isValidGadgetID( $name ) ) {
229            return false;
230        }
231
232        $info = [
233            'section' => $section,
234            'name' => $name,
235            'definition' => $definition,
236        ];
237
238        foreach ( preg_split( '/\s*\|\s*/', $options, -1, PREG_SPLIT_NO_EMPTY ) as $option ) {
239            $arr = preg_split( '/\s*=\s*/', $option, 2 );
240            $option = $arr[0];
241            if ( isset( $arr[1] ) ) {
242                $params = explode( ',', $arr[1] );
243                $params = array_map( 'trim', $params );
244            } else {
245                $params = [];
246            }
247
248            switch ( $option ) {
249                case 'ResourceLoader':
250                    $info['resourceLoaded'] = true;
251                    break;
252                case 'requiresES6':
253                    $info['requiresES6'] = true;
254                    break;
255                case 'dependencies':
256                    $info['dependencies'] = $params;
257                    break;
258                case 'peers':
259                    $info['peers'] = $params;
260                    break;
261                case 'codexIcons':
262                    $info['codexIcons'] = $params;
263                    break;
264                case 'rights':
265                    $info['requiredRights'] = $params;
266                    break;
267                case 'hidden':
268                    $info['hidden'] = true;
269                    break;
270                case 'actions':
271                    $info['requiredActions'] = $params;
272                    break;
273                case 'skins':
274                    $info['requiredSkins'] = $params;
275                    break;
276                case 'namespaces':
277                    $info['requiredNamespaces'] = $params;
278                    break;
279                case 'categories':
280                    $info['requiredCategories'] = $params;
281                    break;
282                case 'contentModels':
283                    $info['requiredContentModels'] = $params;
284                    break;
285                case 'default':
286                    $info['onByDefault'] = true;
287                    break;
288                case 'package':
289                    $info['package'] = true;
290                    break;
291                case 'type':
292                    // Single value, not a list
293                    $info['type'] = $params[0] ?? '';
294                    break;
295                case 'supportsUrlLoad':
296                    $val = $params[0] ?? '';
297                    $info['supportsUrlLoad'] = $val !== 'false';
298                    break;
299            }
300        }
301
302        foreach ( preg_split( '/\s*\|\s*/', $pages, -1, PREG_SPLIT_NO_EMPTY ) as $page ) {
303            $info['pages'][] = self::RESOURCE_TITLE_PREFIX . trim( $page );
304        }
305
306        return new Gadget( $info );
307    }
308}