Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 312
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
0.00% covered (danger)
0.00%
0 / 312
0.00% covered (danger)
0.00%
0 / 9
2862
0.00% covered (danger)
0.00%
0 / 1
 onEditPage__importFormData
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 wrapErrorMsg
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 onAlternateEditPreview
0.00% covered (danger)
0.00%
0 / 104
0.00% covered (danger)
0.00%
0 / 1
342
 onEditPage__showStandardInputs_options
0.00% covered (danger)
0.00%
0 / 86
0.00% covered (danger)
0.00%
0 / 1
72
 isUsableApiModule
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 onAPIGetAllowedParams
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 onApiMakeParserOptions
0.00% covered (danger)
0.00%
0 / 75
0.00% covered (danger)
0.00%
0 / 1
306
 getParsedMessages
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getTemplateNamespaces
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\TemplateSandbox;
4
5// phpcs:disable MediaWiki.NamingConventions.LowerCamelFunctionsName.FunctionName
6
7use ApiBase;
8use ApiExpandTemplates;
9use ApiParse;
10use Content;
11use ExtensionRegistry;
12use IContextSource;
13use MediaWiki\Api\Hook\APIGetAllowedParamsHook;
14use MediaWiki\Api\Hook\ApiMakeParserOptionsHook;
15use MediaWiki\Config\Config;
16use MediaWiki\EditPage\EditPage;
17use MediaWiki\Hook\AlternateEditPreviewHook;
18use MediaWiki\Hook\EditPage__importFormDataHook;
19use MediaWiki\Hook\EditPage__showStandardInputs_optionsHook;
20use MediaWiki\Html\Html;
21use MediaWiki\MediaWikiServices;
22use MediaWiki\Output\OutputPage;
23use MediaWiki\Parser\ParserOutput;
24use MediaWiki\Request\WebRequest;
25use MediaWiki\ResourceLoader as RL;
26use MediaWiki\Revision\RevisionRecord;
27use MediaWiki\Revision\SlotRecord;
28use MediaWiki\Title\Title;
29use MediaWiki\Widget\TitleInputWidget;
30use MWContentSerializationException;
31use OOUI\ActionFieldLayout;
32use OOUI\ButtonInputWidget;
33use OOUI\FieldsetLayout;
34use OOUI\HtmlSnippet;
35use OOUI\Layout;
36use ParserOptions;
37use RequestContext;
38use Wikimedia\ParamValidator\ParamValidator;
39use Wikimedia\ScopedCallback;
40use Xml;
41
42class Hooks implements
43    EditPage__importFormDataHook,
44    EditPage__showStandardInputs_optionsHook,
45    AlternateEditPreviewHook,
46    APIGetAllowedParamsHook,
47    ApiMakeParserOptionsHook
48{
49    private static $counter = 0;
50
51    /**
52     * Hook for EditPage::importFormData to parse our new form fields, and if
53     * necessary put $editpage into "preview" mode.
54     *
55     * Note we specifically do not check $wgTemplateSandboxEditNamespaces here,
56     * since users can manually enable this for other namespaces.
57     *
58     * @param EditPage $editpage
59     * @param WebRequest $request
60     */
61    public function onEditPage__importFormData( $editpage, $request ) {
62        $editpage->templatesandbox_template = $request->getText(
63            'wpTemplateSandboxTemplate', $editpage->getTitle()->getPrefixedText()
64        );
65        $editpage->templatesandbox_page = $request->getText( 'wpTemplateSandboxPage' );
66
67        if ( $request->wasPosted() ) {
68            if ( $request->getCheck( 'wpTemplateSandboxPreview' ) ) {
69                $editpage->templatesandbox_preview = true;
70                $editpage->preview = true;
71                $editpage->save = false;
72                $editpage->live = false;
73            }
74        }
75    }
76
77    /**
78     * @param IContextSource $context
79     * @param string $msg
80     * @return string
81     */
82    private static function wrapErrorMsg( IContextSource $context, $msg ) {
83        return "<div id='mw-$msg'>\n"
84            . $context->msg( $msg )->parseAsBlock()
85            . "\n</div>";
86    }
87
88    /**
89     * Hook for AlternateEditPreview to output an entirely different preview
90     * when our button was clicked.
91     *
92     * @param EditPage $editpage
93     * @param Content &$content
94     * @param string &$out
95     * @param ParserOutput &$parserOutput
96     * @return bool
97     */
98    public function onAlternateEditPreview( $editpage, &$content, &$out,
99        &$parserOutput
100    ) {
101        if ( !isset( $editpage->templatesandbox_preview ) ) {
102            return true;
103        }
104
105        $context = $editpage->getContext();
106
107        if ( $editpage->templatesandbox_template === '' ||
108            $editpage->templatesandbox_template === null
109        ) {
110            $out = self::wrapErrorMsg( $context, 'templatesandbox-editform-need-template' );
111            return false;
112        }
113        if ( $editpage->templatesandbox_page === '' || $editpage->templatesandbox_page === null ) {
114            $out = self::wrapErrorMsg( $context, 'templatesandbox-editform-need-title' );
115            return false;
116        }
117
118        $templatetitle = Title::newFromText( $editpage->templatesandbox_template );
119        if ( !$templatetitle instanceof Title ) {
120            $out = self::wrapErrorMsg( $context, 'templatesandbox-editform-invalid-template' );
121            return false;
122        }
123
124        $title = Title::newFromText( $editpage->templatesandbox_page );
125        if ( !$title instanceof Title ) {
126            $out = self::wrapErrorMsg( $context, 'templatesandbox-editform-invalid-title' );
127            return false;
128        }
129
130        // If we're previewing the same page we're editing, we don't need to check whether
131        // we exist, since we fake that we exist later. This is useful to, for example,
132        // preview a page move.
133        if ( !$title->equals( $templatetitle ) && !$title->exists() ) {
134            $out = self::wrapErrorMsg( $context, 'templatesandbox-editform-title-not-exists' );
135            return false;
136        }
137
138        $note = '';
139        $dtitle = false;
140        $parserOutput = null;
141
142        $user = $context->getUser();
143        $output = $context->getOutput();
144        $lang = $context->getLanguage();
145
146        try {
147            if ( $editpage->sectiontitle !== '' ) {
148                // TODO (T314475): If sectiontitle is null this uses '' rather than summary; is that wanted?
149                $sectionTitle = $editpage->sectiontitle ?? '';
150            } else {
151                $sectionTitle = $editpage->summary;
152            }
153
154            if ( $editpage->getArticle()->getPage()->exists() ) {
155                $content = $editpage->getArticle()->getPage()->replaceSectionContent(
156                    $editpage->section, $content, $sectionTitle, $editpage->edittime
157                );
158                if ( $content === null ) {
159                    $out = self::wrapErrorMsg( $context, 'templatesandbox-failed-replace-section' );
160                    return false;
161                }
162            } else {
163                if ( $editpage->section === 'new' ) {
164                    $content = $content->addSectionHeader( $sectionTitle );
165                }
166            }
167
168            // Apply PST to the to-be-saved text
169            $popts = $editpage->getArticle()->getPage()->makeParserOptions(
170                $context
171            );
172            $services = MediaWikiServices::getInstance();
173            $popts->setIsPreview( true );
174            $popts->setIsSectionPreview( false );
175            $contentTransformer = $services->getContentTransformer();
176            $content = $contentTransformer->preSaveTransform(
177                $content,
178                $templatetitle,
179                $user,
180                $popts
181            );
182
183            $note = $context->msg( 'templatesandbox-previewnote', $title->getPrefixedText() )->plain() .
184                ' [[#' . EditPage::EDITFORM_ID . '|' . $lang->getArrow() . ' ' .
185                $context->msg( 'continue-editing' )->text() . ']]';
186
187            $page = $services->getWikiPageFactory()->newFromTitle( $title );
188            $popts = $page->makeParserOptions( $context );
189            $popts->setIsPreview( true );
190            $popts->setIsSectionPreview( false );
191            $logic = new Logic( [], $templatetitle, $content );
192            $reset = $logic->setupForParse( $popts );
193
194            $revRecord = call_user_func_array(
195                $popts->getCurrentRevisionRecordCallback(),
196                [ $title ]
197            );
198
199            $pageContent = $revRecord->getContent(
200                SlotRecord::MAIN,
201                RevisionRecord::FOR_THIS_USER,
202                $user
203            );
204            $contentRenderer = $services->getContentRenderer();
205            $parserOutput = $contentRenderer->getParserOutput( $pageContent, $title, $revRecord, $popts );
206
207            $output->addParserOutputMetadata( $parserOutput );
208            if ( $output->userCanPreview() ) {
209                $output->addContentOverride( $templatetitle, $content );
210            }
211
212            $dtitle = $parserOutput->getDisplayTitle();
213            $parserOutput->setTitleText( '' );
214            $skinOptions = $output->getSkin()->getOptions();
215            $out = $parserOutput->getText( [
216                'injectTOC' => $skinOptions['toc'],
217                'enableSectionEditLinks' => false,
218                'includeDebugInfo' => true,
219            ] );
220
221            if ( count( $parserOutput->getWarnings() ) ) {
222                $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() );
223            }
224        } catch ( MWContentSerializationException $ex ) {
225            $m = $context->msg( 'content-failed-to-parse',
226                $editpage->contentModel, $editpage->contentFormat, $ex->getMessage()
227            );
228            $note .= "\n\n" . $m->parse();
229            $out = '';
230        }
231
232        $dtitle = $dtitle === false ? $title->getPrefixedText() : $dtitle;
233        $previewhead = Html::rawElement(
234            'div', [ 'class' => 'previewnote' ],
235            Html::rawElement(
236                'h2', [ 'id' => 'mw-previewheader' ],
237                $context->msg( 'templatesandbox-preview', $title->getPrefixedText(), $dtitle )->parse()
238            ) .
239            Html::warningBox(
240                $output->parseAsInterface( $note )
241            )
242        );
243
244        $out = $previewhead . $out . $editpage->previewTextAfterContent;
245
246        return false;
247    }
248
249    /**
250     * Hook for EditPage::showStandardInputs:options to add our form fields to
251     * the "editOptions" area of the page.
252     *
253     * @param EditPage $editpage
254     * @param OutputPage $output
255     * @param int &$tabindex
256     */
257    public function onEditPage__showStandardInputs_options( $editpage, $output, &$tabindex ) {
258        global $wgTemplateSandboxEditNamespaces;
259
260        $namespaces = array_merge(
261            $wgTemplateSandboxEditNamespaces,
262            ExtensionRegistry::getInstance()->getAttribute( 'TemplateSandboxEditNamespaces' )
263        );
264
265        $contentModels = ExtensionRegistry::getInstance()->getAttribute(
266            'TemplateSandboxEditContentModels' );
267
268        // Show the form if the title is in an allowed namespace, has an allowed content model
269        // or if the user requested it with &wpTemplateSandboxShow
270        $showForm = $editpage->getTitle()->inNamespaces( $namespaces )
271            || in_array( $editpage->getTitle()->getContentModel(), $contentModels, true )
272            || $output->getRequest()->getCheck( 'wpTemplateSandboxShow' );
273
274        if ( !$showForm ) {
275            // output the values in hidden fields so that a user
276            // using a gadget doesn't have to re-enter them every time
277
278            $html = Xml::openElement( 'span', [ 'id' => 'templatesandbox-editform' ] );
279
280            $html .= Html::hidden( 'wpTemplateSandboxTemplate',
281                $editpage->templatesandbox_template, [ 'id' => 'wpTemplateSandboxTemplate' ]
282            );
283
284            $html .= Html::hidden( 'wpTemplateSandboxPage',
285                $editpage->templatesandbox_page, [ 'id' => 'wpTemplateSandboxPage' ]
286            );
287
288            $html .= Xml::closeElement( 'span' );
289
290            $output->addHTML( $html . "\n" );
291
292            return;
293        }
294
295        $output->addModuleStyles( 'ext.TemplateSandbox.top' );
296        $output->addModules( 'ext.TemplateSandbox' );
297
298        $context = $editpage->getContext();
299
300        $textHtml = '';
301        $text = $context->msg( 'templatesandbox-editform-text' );
302        if ( !$text->isDisabled() ) {
303            $textAttrs = [
304                'class' => 'mw-templatesandbox-editform-text',
305            ];
306            $textHtml = Xml::tags( 'div', $textAttrs, $text->parse() );
307        }
308
309        $helptextHtml = '';
310        $helptext = $context->msg( 'templatesandbox-editform-helptext' );
311        if ( !$helptext->isDisabled() ) {
312            $helptextAttrs = [
313                'class' => 'mw-templatesandbox-editform-helptext',
314            ];
315            $helptextHtml = Xml::tags( 'span', $helptextAttrs, $helptext->parse() );
316        }
317
318        $hiddenInputsHtml =
319            Html::hidden( 'wpTemplateSandboxTemplate',
320                $editpage->templatesandbox_template, [ 'id' => 'wpTemplateSandboxTemplate' ]
321            ) .
322            // If they submit our form, pass the parameter along for not allowed namespaces
323            Html::hidden( 'wpTemplateSandboxShow', '' );
324
325        $output->enableOOUI();
326        $output->addModules( 'oojs-ui-core' );
327        $output->addModules( 'mediawiki.widgets' );
328
329        $fieldsetLayout =
330            new FieldsetLayout( [
331                'label' => new HtmlSnippet( $context->msg( 'templatesandbox-editform-legend' )->parse() ),
332                'id' => 'templatesandbox-editform',
333                'classes' => [ 'mw-templatesandbox-fieldset' ],
334                'items' => [
335                    // TODO: OOUI should provide a plain content layout, as this is
336                    // technically an abstract class
337                    new Layout( [
338                        'content' => new HtmlSnippet( $textHtml . "\n" . $hiddenInputsHtml )
339                    ] ),
340                    new ActionFieldLayout(
341                        new TitleInputWidget( [
342                            'id' => 'wpTemplateSandboxPage',
343                            'name' => 'wpTemplateSandboxPage',
344                            'value' => $editpage->templatesandbox_page,
345                            'tabIndex' => ++$tabindex,
346                            'placeholder' => $context->msg( 'templatesandbox-editform-page-label' )->text(),
347                            'infusable' => true,
348                        ] ),
349                        new ButtonInputWidget( [
350                            'id' => 'wpTemplateSandboxPreview',
351                            'name' => 'wpTemplateSandboxPreview',
352                            'label' => $context->msg( 'templatesandbox-editform-view-label' )->text(),
353                            'tabIndex' => ++$tabindex,
354                            'type' => 'submit',
355                            'useInputTag' => true,
356                        ] ),
357                        [ 'align' => 'top' ]
358                    )
359                ]
360            ] );
361
362        if ( $helptextHtml ) {
363            $fieldsetLayout->addItems( [
364                // TODO: OOUI should provide a plain content layout, as this is
365                // technically an abstract class
366                new Layout( [
367                    'content' => new HtmlSnippet( $helptextHtml )
368                ] )
369            ] );
370        }
371        $output->addHTML( $fieldsetLayout );
372
373        $optionsLookup = MediaWikiServices::getInstance()->getUserOptionsLookup();
374        if ( $optionsLookup->getOption( $context->getUser(), 'uselivepreview' ) ) {
375            $output->addModules( 'ext.TemplateSandbox.preview' );
376        }
377    }
378
379    /**
380     * Determine if this API module is appropriate for us to mess with.
381     * @param ApiBase $module
382     * @return bool
383     */
384    private static function isUsableApiModule( $module ) {
385        return $module instanceof ApiParse || $module instanceof ApiExpandTemplates;
386    }
387
388    /**
389     * Hook for APIGetAllowedParams to add our API parameters to the relevant
390     * modules.
391     *
392     * @param ApiBase $module
393     * @param array &$params
394     * @param int $flags
395     */
396    public function onAPIGetAllowedParams( $module, &$params, $flags ) {
397        if ( !self::isUsableApiModule( $module ) ) {
398            return;
399        }
400
401        $contentHandlerFactory = MediaWikiServices::getInstance()->getContentHandlerFactory();
402        $params += [
403            'templatesandboxprefix' => [
404                ParamValidator::PARAM_TYPE => 'string',
405                ParamValidator::PARAM_ISMULTI => true,
406                ApiBase::PARAM_HELP_MSG => 'templatesandbox-apihelp-prefix',
407            ],
408            'templatesandboxtitle' => [
409                ParamValidator::PARAM_TYPE => 'string',
410                ApiBase::PARAM_HELP_MSG => 'templatesandbox-apihelp-title',
411            ],
412            'templatesandboxtext' => [
413                ParamValidator::PARAM_TYPE => 'text',
414                ApiBase::PARAM_HELP_MSG => 'templatesandbox-apihelp-text',
415            ],
416            'templatesandboxcontentmodel' => [
417                ParamValidator::PARAM_TYPE => $contentHandlerFactory->getContentModels(),
418                ApiBase::PARAM_HELP_MSG => 'templatesandbox-apihelp-contentmodel',
419            ],
420            'templatesandboxcontentformat' => [
421                ParamValidator::PARAM_TYPE => $contentHandlerFactory->getAllContentFormats(),
422                ApiBase::PARAM_HELP_MSG => 'templatesandbox-apihelp-contentformat',
423            ],
424        ];
425    }
426
427    /**
428     * Hook for ApiMakeParserOptions to set things up for TemplateSandbox
429     * parsing when necessary.
430     *
431     * @param ParserOptions $options
432     * @param Title $title
433     * @param array $params
434     * @param ApiBase $module
435     * @param null &$reset Set to a ScopedCallback used to reset any hooks set.
436     * @param bool &$suppressCache
437     */
438    public function onApiMakeParserOptions(
439        $options, $title, $params, $module, &$reset, &$suppressCache
440    ) {
441        // Shouldn't happen, but...
442        if ( !self::isUsableApiModule( $module ) ) {
443            return;
444        }
445
446        $params += [
447            'templatesandboxprefix' => [],
448            'templatesandboxtitle' => null,
449            'templatesandboxtext' => null,
450            'templatesandboxcontentmodel' => null,
451            'templatesandboxcontentformat' => null,
452        ];
453        $params = [
454            // @phan-suppress-next-line PhanImpossibleCondition
455            'prefix' => $params['templatesandboxprefix'] ?: [],
456            'title' => $params['templatesandboxtitle'],
457            'text' => $params['templatesandboxtext'],
458            'contentmodel' => $params['templatesandboxcontentmodel'],
459            'contentformat' => $params['templatesandboxcontentformat'],
460        ];
461
462        if ( ( $params['title'] === null ) !== ( $params['text'] === null ) ) {
463            $p = $module->getModulePrefix();
464            $module->dieWithError( [ 'templatesandbox-apierror-titleandtext', $p ], 'invalidparammix' );
465        }
466
467        $prefixes = [];
468        foreach ( $params['prefix'] as $prefix ) {
469            $prefixTitle = Title::newFromText( rtrim( $prefix, '/' ) );
470            if ( !$prefixTitle instanceof Title || $prefixTitle->getFragment() !== '' ||
471                $prefixTitle->isExternal()
472            ) {
473                $p = $module->getModulePrefix();
474                $module->dieWithError(
475                    [ 'apierror-badparameter', "{$p}templatesandboxprefix" ], "bad_{$p}templatesandboxprefix"
476                );
477            }
478            $prefixes[] = $prefixTitle->getPrefixedText();
479        }
480
481        if ( $params['title'] !== null ) {
482            $page = $module->getTitleOrPageId( $params );
483            if ( $params['contentmodel'] == '' ) {
484                $contentHandler = $page->getContentHandler();
485            } else {
486                $contentHandler = MediaWikiServices::getInstance()->getContentHandlerFactory()
487                    // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
488                    ->getContentHandler( $params['contentmodel'] );
489            }
490
491            $escName = wfEscapeWikiText( $page->getTitle()->getPrefixedDBkey() );
492            $model = $contentHandler->getModelID();
493
494            if ( $contentHandler->supportsDirectApiEditing() === false ) {
495                $module->dieWithError( [ 'apierror-no-direct-editing', $model, $escName ] );
496            }
497
498            // @phan-suppress-next-line PhanImpossibleCondition
499            $format = $params['contentformat'] ?: $contentHandler->getDefaultFormat();
500            if ( !$contentHandler->isSupportedFormat( $format ) ) {
501                $module->dieWithError( [ 'apierror-badformat', $format, $model, $escName ] );
502            }
503
504            $templatetitle = $page->getTitle();
505            $content = $contentHandler->makeContent( $params['text'], $page->getTitle(), $model, $format );
506
507            // Apply PST to templatesandboxtext
508            $popts = $page->makeParserOptions( $module );
509            $popts->setIsPreview( true );
510            $popts->setIsSectionPreview( false );
511            $user = RequestContext::getMain()->getUser();
512            $contentTransformer = MediaWikiServices::getInstance()->getContentTransformer();
513            $content = $contentTransformer->preSaveTransform(
514                $content,
515                $templatetitle,
516                $user,
517                $popts
518            );
519        } else {
520            $templatetitle = null;
521            $content = null;
522        }
523
524        if ( $prefixes || $templatetitle ) {
525            $logic = new Logic( $prefixes, $templatetitle, $content );
526            $resetLogic = $logic->setupForParse( $options );
527            $suppressCache = true;
528
529            $hookContainer = MediaWikiServices::getInstance()->getHookContainer();
530            $resetHook = $hookContainer->scopedRegister( 'ApiParseMakeOutputPage', static function ( $module, $output )
531                use ( $prefixes, $templatetitle, $content )
532            {
533                if ( $prefixes ) {
534                    Logic::addSubpageHandlerToOutput( $prefixes, $output );
535                }
536                if ( $templatetitle ) {
537                    $output->addContentOverride( $templatetitle, $content );
538                }
539            } );
540
541            $reset = new ScopedCallback( static function () use ( &$resetLogic, &$resetHook ) {
542                ScopedCallback::consume( $resetHook );
543                ScopedCallback::consume( $resetLogic );
544            } );
545        }
546    }
547
548    /**
549     * Function that returns an array of parsed messages used in live preview
550     * for the ResourceLoader
551     *
552     * @param RL\Context $context
553     * @return array
554     */
555    public static function getParsedMessages( $context ) {
556        return [
557            'templatesandbox-previewnote' => $context->msg( 'templatesandbox-previewnote' )->parse(),
558        ];
559    }
560
561    /**
562     * Function that returns an array of valid namespaces to show the page
563     * preview form on for the ResourceLoader
564     *
565     * @param RL\Context $context
566     * @param Config $config
567     * @return array
568     */
569    public static function getTemplateNamespaces( $context, $config ) {
570        return array_merge(
571            $config->get( 'TemplateSandboxEditNamespaces' ),
572            ExtensionRegistry::getInstance()->getAttribute( 'TemplateSandboxEditNamespaces' )
573        );
574    }
575
576}