Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 186
0.00% covered (danger)
0.00%
0 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
JCHooks
0.00% covered (danger)
0.00%
0 / 186
0.00% covered (danger)
0.00%
0 / 22
6972
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 onCanonicalNamespaces
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
56
 onContentHandlerDefaultModelFor
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 onGetContentModels
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 onContentHandlerForModelID
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 onAlternateEdit
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 onEditPage__showEditForm_initial
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 onEditFilterMergedContent
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 getTitleLicenseCode
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 onEditPageCopyrightWarning
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 onTitleGetEditNotices
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 onSkinCopyrightFooter
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 onBeforePageDisplay
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 onMovePageIsValidMove
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 onApiMain__moduleManager
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 onPageSaveComplete
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onArticleDeleteComplete
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onArticleUndelete
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onPageMoveComplete
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 onGetUserPermissionsErrors
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 onArticleChangeComplete
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
110
 jsonConfigIsStorage
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2
3// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
4
5namespace JsonConfig;
6
7use ApiModuleManager;
8use Content;
9use IContextSource;
10use MediaWiki\Api\Hook\ApiMain__moduleManagerHook;
11use MediaWiki\Config\Config;
12use MediaWiki\Content\Hook\ContentHandlerForModelIDHook;
13use MediaWiki\Content\Hook\GetContentModelsHook;
14use MediaWiki\Content\IContentHandlerFactory;
15use MediaWiki\EditPage\EditPage;
16use MediaWiki\Hook\AlternateEditHook;
17use MediaWiki\Hook\BeforePageDisplayHook;
18use MediaWiki\Hook\CanonicalNamespacesHook;
19use MediaWiki\Hook\EditFilterMergedContentHook;
20use MediaWiki\Hook\EditPage__showEditForm_initialHook;
21use MediaWiki\Hook\EditPageCopyrightWarningHook;
22use MediaWiki\Hook\MovePageIsValidMoveHook;
23use MediaWiki\Hook\PageMoveCompleteHook;
24use MediaWiki\Hook\SkinCopyrightFooterHook;
25use MediaWiki\Hook\TitleGetEditNoticesHook;
26use MediaWiki\Html\Html;
27use MediaWiki\Output\OutputPage;
28use MediaWiki\Page\Hook\ArticleDeleteCompleteHook;
29use MediaWiki\Page\Hook\ArticleUndeleteHook;
30use MediaWiki\Permissions\Hook\GetUserPermissionsErrorsHook;
31use MediaWiki\Revision\Hook\ContentHandlerDefaultModelForHook;
32use MediaWiki\Status\Status;
33use MediaWiki\Storage\Hook\PageSaveCompleteHook;
34use MediaWiki\Title\Title;
35use MediaWiki\User\User;
36use MessageSpecifier;
37
38/**
39 * Hook handlers for JsonConfig extension.
40 *
41 * @file
42 * @ingroup Extensions
43 * @ingroup JsonConfig
44 * @license GPL-2.0-or-later
45 */
46class JCHooks implements
47    ApiMain__moduleManagerHook,
48    ArticleDeleteCompleteHook,
49    ArticleUndeleteHook,
50    BeforePageDisplayHook,
51    CanonicalNamespacesHook,
52    ContentHandlerDefaultModelForHook,
53    ContentHandlerForModelIDHook,
54    GetContentModelsHook,
55    AlternateEditHook,
56    EditPage__showEditForm_initialHook,
57    EditFilterMergedContentHook,
58    EditPageCopyrightWarningHook,
59    MovePageIsValidMoveHook,
60    PageSaveCompleteHook,
61    SkinCopyrightFooterHook,
62    TitleGetEditNoticesHook,
63    PageMoveCompleteHook,
64    GetUserPermissionsErrorsHook
65{
66    private Config $config;
67    private IContentHandlerFactory $contentHandlerFactory;
68
69    public function __construct(
70        Config $config,
71        IContentHandlerFactory $contentHandlerFactory
72    ) {
73        $this->config = $config;
74        $this->contentHandlerFactory = $contentHandlerFactory;
75    }
76
77    /**
78     * Only register NS_CONFIG if running on the MediaWiki instance which houses
79     * the JSON configs (i.e. META)
80     * @param array &$namespaces
81     */
82    public function onCanonicalNamespaces( &$namespaces ) {
83        if ( !self::jsonConfigIsStorage( $this->config ) ) {
84            return;
85        }
86
87        JCSingleton::init();
88        foreach ( JCSingleton::$namespaces as $ns => $name ) {
89            if ( $name === false ) { // must be already declared
90                if ( !array_key_exists( $ns, $namespaces ) ) {
91                    wfLogWarning( "JsonConfig: Invalid \$wgJsonConfigs: Namespace $ns " .
92                        "has not been declared by core or other extensions" );
93                }
94            } elseif ( array_key_exists( $ns, $namespaces ) ) {
95                wfLogWarning( "JsonConfig: Invalid \$wgJsonConfigs: Namespace $ns => '$name" .
96                    "is already declared as '$namespaces[$ns]'" );
97            } else {
98                $key = array_search( $name, $namespaces );
99                if ( $key !== false ) {
100                    wfLogWarning( "JsonConfig: Invalid \$wgJsonConfigs: Namespace $ns => '$name" .
101                        "has identical name with the namespace #$key" );
102                } else {
103                    $namespaces[$ns] = $name;
104                }
105            }
106        }
107    }
108
109    /**
110     * Initialize state
111     * @param Title $title
112     * @param string &$modelId
113     * @return bool
114     */
115    public function onContentHandlerDefaultModelFor( $title, &$modelId ) {
116        if ( !self::jsonConfigIsStorage( $this->config ) ) {
117            return true;
118        }
119
120        $jct = JCSingleton::parseTitle( $title );
121        if ( $jct ) {
122            $modelId = $jct->getConfig()->model;
123            return false;
124        }
125        return true;
126    }
127
128    /**
129     * Ensure that ContentHandler knows about our dynamic models (T259126)
130     * @param string[] &$models
131     */
132    public function onGetContentModels( &$models ) {
133        if ( !self::jsonConfigIsStorage( $this->config ) ) {
134            return;
135        }
136
137        JCSingleton::init();
138        // TODO: this is copied from onContentHandlerForModelID()
139        $ourModels = array_replace_recursive(
140            \ExtensionRegistry::getInstance()->getAttribute( 'JsonConfigModels' ),
141            $this->config->get( 'JsonConfigModels' )
142        );
143        $models = array_merge( $models, array_keys( $ourModels ) );
144    }
145
146    /**
147     * Instantiate JCContentHandler if we can handle this modelId
148     * @param string $modelId
149     * @param \ContentHandler &$handler
150     * @return bool
151     */
152    public function onContentHandlerForModelID( $modelId, &$handler ) {
153        if ( !self::jsonConfigIsStorage( $this->config ) ) {
154            return true;
155        }
156
157        JCSingleton::init();
158        $models = array_replace_recursive(
159            \ExtensionRegistry::getInstance()->getAttribute( 'JsonConfigModels' ),
160            $this->config->get( 'JsonConfigModels' )
161        );
162        if ( array_key_exists( $modelId, $models ) ) {
163            // This is one of our model IDs
164            $handler = new JCContentHandler( $modelId );
165            return false;
166        }
167        return true;
168    }
169
170    /**
171     * AlternateEdit hook handler
172     * @see https://www.mediawiki.org/wiki/Manual:Hooks/AlternateEdit
173     * @param EditPage $editpage
174     */
175    public function onAlternateEdit( $editpage ) {
176        if ( !self::jsonConfigIsStorage( $this->config ) ) {
177            return;
178        }
179        $jct = JCSingleton::parseTitle( $editpage->getTitle() );
180        if ( $jct ) {
181            $editpage->contentFormat = JCContentHandler::CONTENT_FORMAT_JSON_PRETTY;
182        }
183    }
184
185    /**
186     * @param EditPage $editPage
187     * @param OutputPage $output
188     */
189    public function onEditPage__showEditForm_initial( $editPage, $output ) {
190        if (
191            $output->getConfig()->get( 'JsonConfigUseGUI' ) &&
192            $editPage->getTitle()->getContentModel() === 'Tabular.JsonConfig'
193        ) {
194            $output->addModules( 'ext.jsonConfig.edit' );
195        }
196    }
197
198    /**
199     * Validates that the revised contents are valid JSON.
200     * If not valid, rejects edit with error message.
201     * @param IContextSource $context
202     * @param Content $content
203     * @param Status $status
204     * @param string $summary Edit summary provided for edit.
205     * @param User $user
206     * @param bool $minoredit
207     * @return bool
208     */
209    public function onEditFilterMergedContent(
210        /** @noinspection PhpUnusedParameterInspection */
211        IContextSource $context, Content $content, Status $status, $summary, User $user, $minoredit
212    ) {
213        if ( !self::jsonConfigIsStorage( $this->config ) ) {
214            return true;
215        }
216
217        if ( $content instanceof JCContent ) {
218            $status->merge( $content->getStatus() );
219            if ( !$status->isGood() ) {
220                // @todo Use $status->setOK() instead after this extension
221                // do not support mediawiki version 1.36 and before
222                $status->setResult( false, $status->getValue() ?: EditPage::AS_HOOK_ERROR_EXPECTED );
223                return false;
224            }
225        }
226        return true;
227    }
228
229    /**
230     * Get the license code for the title or false otherwise.
231     * license code is identifier from https://spdx.org/licenses/
232     *
233     * @param JCTitle $jct
234     * @return bool|string Returns licence code string, or false if license is unknown
235     */
236    private static function getTitleLicenseCode( JCTitle $jct ) {
237        $jctContent = JCSingleton::getContent( $jct );
238        if ( $jctContent && $jctContent instanceof JCDataContent ) {
239            $license = $jctContent->getLicenseObject();
240            if ( $license ) {
241                return $license['code'];
242            }
243        }
244        return false;
245    }
246
247    /**
248     * Override a per-page specific edit page copyright warning
249     *
250     * @param Title $title
251     * @param string[] &$msg
252     *
253     * @return bool
254     */
255    public function onEditPageCopyrightWarning( $title, &$msg ) {
256        if ( self::jsonConfigIsStorage( $this->config ) ) {
257            $jct = JCSingleton::parseTitle( $title );
258            if ( $jct ) {
259                $code = self::getTitleLicenseCode( $jct );
260                if ( $code ) {
261                    $msg = [ 'jsonconfig-license-copyrightwarning', $code ];
262                } else {
263                    $requireLicense = $jct->getConfig()->license ?? false;
264                    // Check if page has license field to apply only if it is required
265                    // https://phabricator.wikimedia.org/T203173
266                    if ( $requireLicense ) {
267                        $msg = [ 'jsonconfig-license-copyrightwarning-license-unset' ];
268                    }
269                }
270                return false; // Do not allow any other hook handler to override this
271            }
272        }
273        return true;
274    }
275
276    /**
277     * Display a page-specific edit notice
278     *
279     * @param Title $title
280     * @param int $oldid
281     * @param array &$notices
282     */
283    public function onTitleGetEditNotices( $title, $oldid, &$notices ) {
284        if ( self::jsonConfigIsStorage( $this->config ) ) {
285            $jct = JCSingleton::parseTitle( $title );
286            if ( $jct ) {
287                $code = self::getTitleLicenseCode( $jct );
288                if ( $code ) {
289                    $noticeText = wfMessage( 'jsonconfig-license-notice', $code )->parse();
290                    $iconCodes = '';
291                    if ( preg_match_all( "/[a-z][a-z0-9]+/i", $code, $subcodes ) ) {
292                        // Flip order due to dom ordering of the floating elements
293                        foreach ( array_reverse( $subcodes[0] ) as $c => $match ) {
294                            // Used classes:
295                            // * mw-jsonconfig-editnotice-icon-BY
296                            // * mw-jsonconfig-editnotice-icon-CC
297                            // * mw-jsonconfig-editnotice-icon-CC0
298                            // * mw-jsonconfig-editnotice-icon-ODbL
299                            // * mw-jsonconfig-editnotice-icon-SA
300                            $iconCodes .= Html::rawElement(
301                                'span', [ 'class' => 'mw-jsonconfig-editnotice-icon-' . $match ], ''
302                            );
303                        }
304                        $iconCodes = Html::rawElement(
305                            'div', [ 'class' => 'mw-jsonconfig-editnotice-icons' ], $iconCodes
306                        );
307                    }
308
309                    $noticeFooter = Html::rawElement(
310                        'div', [ 'class' => 'mw-jsonconfig-editnotice-footer' ], ''
311                    );
312
313                    $notices['jsonconfig'] = Html::rawElement(
314                        'div',
315                        [ 'class' => 'mw-jsonconfig-editnotice' ],
316                        $iconCodes . $noticeText . $noticeFooter
317                    );
318                } else {
319                    // Check if page has license field to apply notice msgs only when license is required
320                    // https://phabricator.wikimedia.org/T203173
321                    $requireLicense = $jct->getConfig()->license ?? false;
322                    if ( $requireLicense ) {
323                        $notices['jsonconfig'] = wfMessage( 'jsonconfig-license-notice-license-unset' )->parse();
324                    }
325                }
326            }
327        }
328    }
329
330    /**
331     * Override with per-page specific copyright message
332     *
333     * @param Title $title
334     * @param string $type
335     * @param string &$msg
336     * @param string &$link
337     *
338     * @return bool
339     */
340    public function onSkinCopyrightFooter( $title, $type, &$msg, &$link ) {
341        if ( self::jsonConfigIsStorage( $this->config ) ) {
342            $jct = JCSingleton::parseTitle( $title );
343            if ( $jct ) {
344                $code = self::getTitleLicenseCode( $jct );
345                if ( $code ) {
346                    $msg = 'jsonconfig-license';
347                    $link = Html::element( 'a', [
348                        'href' => wfMessage( 'jsonconfig-license-url-' . $code )->plain()
349                    ], wfMessage( 'jsonconfig-license-name-' . $code )->plain() );
350                    return false;
351                }
352            }
353        }
354        return true;
355    }
356
357    /**
358     * Adds CSS for pretty-printing configuration on NS_CONFIG pages.
359     * @param OutputPage $out
360     * @param \Skin $skin
361     */
362    public function onBeforePageDisplay(
363        /** @noinspection PhpUnusedParameterInspection */ $out, $skin
364    ): void {
365        if ( !self::jsonConfigIsStorage( $this->config ) ) {
366            return;
367        }
368
369        $title = $out->getTitle();
370        // todo/fixme? We should probably add ext.jsonConfig style to only those pages
371        // that pass parseTitle()
372        $handler = $this->contentHandlerFactory
373            ->getContentHandler( $title->getContentModel() );
374        if ( $handler->getDefaultFormat() === CONTENT_FORMAT_JSON ||
375            JCSingleton::parseTitle( $title )
376        ) {
377            $out->addModuleStyles( 'ext.jsonConfig' );
378        }
379    }
380
381    public function onMovePageIsValidMove(
382        $oldTitle, $newTitle, $status
383    ) {
384        if ( !self::jsonConfigIsStorage( $this->config ) ) {
385            return true;
386        }
387
388        $jctOld = JCSingleton::parseTitle( $oldTitle );
389        if ( $jctOld ) {
390            $jctNew = JCSingleton::parseTitle( $newTitle );
391            if ( !$jctNew ) {
392                $status->fatal( 'jsonconfig-move-aborted-ns' );
393                return false;
394            } elseif ( $jctOld->getConfig()->model !== $jctNew->getConfig()->model ) {
395                $status->fatal( 'jsonconfig-move-aborted-model', $jctOld->getConfig()->model,
396                    $jctNew->getConfig()->model );
397                return false;
398            }
399        }
400
401        return true;
402    }
403
404    /**
405     * Conditionally load API module 'jsondata' depending on whether or not
406     * this wiki stores any jsonconfig data
407     *
408     * @param ApiModuleManager $moduleManager Module manager instance
409     */
410    public function onApiMain__moduleManager( $moduleManager ) {
411        if ( $moduleManager->getConfig()->get( 'JsonConfigEnableLuaSupport' ) ) {
412            $moduleManager->addModule( 'jsondata', 'action', JCDataApi::class );
413        }
414    }
415
416    public function onPageSaveComplete(
417        /** @noinspection PhpUnusedParameterInspection */
418        $wikiPage, $user, $summary, $flags, $revisionRecord, $editResult
419    ) {
420        return $this->onArticleChangeComplete( $wikiPage );
421    }
422
423    public function onArticleDeleteComplete(
424        /** @noinspection PhpUnusedParameterInspection */
425        $article, $user, $reason, $id, $content, $logEntry, $archivedRevisionCount
426    ) {
427        return $this->onArticleChangeComplete( $article );
428    }
429
430    public function onArticleUndelete(
431        /** @noinspection PhpUnusedParameterInspection */
432        $title, $created, $comment, $oldPageId, $restoredPages
433    ) {
434        return $this->onArticleChangeComplete( $title );
435    }
436
437    public function onPageMoveComplete(
438        /** @noinspection PhpUnusedParameterInspection */
439        $title, $newTitle, $user, $pageid, $redirid, $reason, $revisionRecord
440    ) {
441        $title = Title::newFromLinkTarget( $title );
442        $newTitle = Title::newFromLinkTarget( $newTitle );
443        return $this->onArticleChangeComplete( $title ) ||
444            $this->onArticleChangeComplete( $newTitle );
445    }
446
447    /**
448     * Prohibit creation of the pages that are part of our namespaces but have not been explicitly
449     * allowed.
450     * @param Title $title
451     * @param User $user
452     * @param string $action
453     * @param array|string|MessageSpecifier &$result
454     * @return bool
455     */
456    public function onGetUserPermissionsErrors(
457        /** @noinspection PhpUnusedParameterInspection */
458        $title, $user, $action, &$result
459    ) {
460        if ( !self::jsonConfigIsStorage( $this->config ) ) {
461            return true;
462        }
463
464        if ( $action === 'create' && JCSingleton::parseTitle( $title ) === null ) {
465            // prohibit creation of the pages for the namespace that we handle,
466            // if the title is not matching declared rules
467            $result = 'jsonconfig-blocked-page-creation';
468            return false;
469        }
470        return true;
471    }
472
473    /**
474     * @param \WikiPage|Title $value
475     * @param JCContent|null $content
476     * @return bool
477     */
478    private function onArticleChangeComplete( $value, $content = null ) {
479        if ( !self::jsonConfigIsStorage( $this->config ) ) {
480            return true;
481        }
482
483        if ( $value && ( !$content || $content instanceof JCContent ) ) {
484            if ( method_exists( $value, 'getTitle' ) ) {
485                $value = $value->getTitle();
486            }
487            $jct = JCSingleton::parseTitle( $value );
488            if ( $jct && $jct->getConfig()->store ) {
489                $store = new JCCache( $jct, $content );
490                $store->resetCache();
491
492                // Handle remote site notification
493                $store = $jct->getConfig()->store;
494                // @phan-suppress-next-line PhanTypeExpectedObjectPropAccess
495                if ( $store->notifyUrl ) {
496                    $req =
497                        // @phan-suppress-next-line PhanTypeExpectedObjectPropAccess
498                        JCUtils::initApiRequestObj( $store->notifyUrl, $store->notifyUsername,
499                            // @phan-suppress-next-line PhanTypeExpectedObjectPropAccess
500                            $store->notifyPassword );
501                    if ( $req ) {
502                        $query = [
503                            'format' => 'json',
504                            'action' => 'jsonconfig',
505                            'command' => 'reload',
506                            'title' => $jct->getNamespace() . ':' . $jct->getDBkey(),
507                        ];
508                        JCUtils::callApi( $req, $query, 'notify remote JsonConfig client' );
509                    }
510                }
511            }
512        }
513        return true;
514    }
515
516    /**
517     * Quick check if the current wiki will store any configurations.
518     * Faster than doing a full parsing of the $wgJsonConfigs in the JCSingleton::init()
519     * @param Config $config
520     * @return bool
521     */
522    public static function jsonConfigIsStorage( Config $config ) {
523        static $isStorage = null;
524        if ( $isStorage === null ) {
525            $isStorage = false;
526            $configs = array_replace_recursive(
527                \ExtensionRegistry::getInstance()->getAttribute( 'JsonConfigs' ),
528                $config->get( 'JsonConfigs' )
529            );
530            foreach ( $configs as $jc ) {
531                if ( ( !array_key_exists( 'isLocal', $jc ) || $jc['isLocal'] ) ||
532                    ( array_key_exists( 'store', $jc ) )
533                ) {
534                    $isStorage = true;
535                    break;
536                }
537            }
538        }
539        return $isStorage;
540    }
541}