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