Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 306
0.00% covered (danger)
0.00%
0 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
0.00% covered (danger)
0.00%
0 / 306
0.00% covered (danger)
0.00%
0 / 8
2652
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 / 83
0.00% covered (danger)
0.00%
0 / 1
56
 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
 getResourceLoaderData
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
374    /**
375     * Determine if this API module is appropriate for us to mess with.
376     * @param ApiBase $module
377     * @return bool
378     */
379    private static function isUsableApiModule( $module ) {
380        return $module instanceof ApiParse || $module instanceof ApiExpandTemplates;
381    }
382
383    /**
384     * Hook for APIGetAllowedParams to add our API parameters to the relevant
385     * modules.
386     *
387     * @param ApiBase $module
388     * @param array &$params
389     * @param int $flags
390     */
391    public function onAPIGetAllowedParams( $module, &$params, $flags ) {
392        if ( !self::isUsableApiModule( $module ) ) {
393            return;
394        }
395
396        $contentHandlerFactory = MediaWikiServices::getInstance()->getContentHandlerFactory();
397        $params += [
398            'templatesandboxprefix' => [
399                ParamValidator::PARAM_TYPE => 'string',
400                ParamValidator::PARAM_ISMULTI => true,
401                ApiBase::PARAM_HELP_MSG => 'templatesandbox-apihelp-prefix',
402            ],
403            'templatesandboxtitle' => [
404                ParamValidator::PARAM_TYPE => 'string',
405                ApiBase::PARAM_HELP_MSG => 'templatesandbox-apihelp-title',
406            ],
407            'templatesandboxtext' => [
408                ParamValidator::PARAM_TYPE => 'text',
409                ApiBase::PARAM_HELP_MSG => 'templatesandbox-apihelp-text',
410            ],
411            'templatesandboxcontentmodel' => [
412                ParamValidator::PARAM_TYPE => $contentHandlerFactory->getContentModels(),
413                ApiBase::PARAM_HELP_MSG => 'templatesandbox-apihelp-contentmodel',
414            ],
415            'templatesandboxcontentformat' => [
416                ParamValidator::PARAM_TYPE => $contentHandlerFactory->getAllContentFormats(),
417                ApiBase::PARAM_HELP_MSG => 'templatesandbox-apihelp-contentformat',
418            ],
419        ];
420    }
421
422    /**
423     * Hook for ApiMakeParserOptions to set things up for TemplateSandbox
424     * parsing when necessary.
425     *
426     * @param ParserOptions $options
427     * @param Title $title
428     * @param array $params
429     * @param ApiBase $module
430     * @param null &$reset Set to a ScopedCallback used to reset any hooks set.
431     * @param bool &$suppressCache
432     */
433    public function onApiMakeParserOptions(
434        $options, $title, $params, $module, &$reset, &$suppressCache
435    ) {
436        // Shouldn't happen, but...
437        if ( !self::isUsableApiModule( $module ) ) {
438            return;
439        }
440
441        $params += [
442            'templatesandboxprefix' => [],
443            'templatesandboxtitle' => null,
444            'templatesandboxtext' => null,
445            'templatesandboxcontentmodel' => null,
446            'templatesandboxcontentformat' => null,
447        ];
448        $params = [
449            // @phan-suppress-next-line PhanImpossibleCondition
450            'prefix' => $params['templatesandboxprefix'] ?: [],
451            'title' => $params['templatesandboxtitle'],
452            'text' => $params['templatesandboxtext'],
453            'contentmodel' => $params['templatesandboxcontentmodel'],
454            'contentformat' => $params['templatesandboxcontentformat'],
455        ];
456
457        if ( ( $params['title'] === null ) !== ( $params['text'] === null ) ) {
458            $p = $module->getModulePrefix();
459            $module->dieWithError( [ 'templatesandbox-apierror-titleandtext', $p ], 'invalidparammix' );
460        }
461
462        $prefixes = [];
463        foreach ( $params['prefix'] as $prefix ) {
464            $prefixTitle = Title::newFromText( rtrim( $prefix, '/' ) );
465            if ( !$prefixTitle instanceof Title || $prefixTitle->getFragment() !== '' ||
466                $prefixTitle->isExternal()
467            ) {
468                $p = $module->getModulePrefix();
469                $module->dieWithError(
470                    [ 'apierror-badparameter', "{$p}templatesandboxprefix" ], "bad_{$p}templatesandboxprefix"
471                );
472            }
473            $prefixes[] = $prefixTitle->getPrefixedText();
474        }
475
476        if ( $params['title'] !== null ) {
477            $page = $module->getTitleOrPageId( $params );
478            if ( $params['contentmodel'] == '' ) {
479                $contentHandler = $page->getContentHandler();
480            } else {
481                $contentHandler = MediaWikiServices::getInstance()->getContentHandlerFactory()
482                    // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
483                    ->getContentHandler( $params['contentmodel'] );
484            }
485
486            $escName = wfEscapeWikiText( $page->getTitle()->getPrefixedDBkey() );
487            $model = $contentHandler->getModelID();
488
489            if ( $contentHandler->supportsDirectApiEditing() === false ) {
490                $module->dieWithError( [ 'apierror-no-direct-editing', $model, $escName ] );
491            }
492
493            // @phan-suppress-next-line PhanImpossibleCondition
494            $format = $params['contentformat'] ?: $contentHandler->getDefaultFormat();
495            if ( !$contentHandler->isSupportedFormat( $format ) ) {
496                $module->dieWithError( [ 'apierror-badformat', $format, $model, $escName ] );
497            }
498
499            $templatetitle = $page->getTitle();
500            $content = $contentHandler->makeContent( $params['text'], $page->getTitle(), $model, $format );
501
502            // Apply PST to templatesandboxtext
503            $popts = $page->makeParserOptions( $module );
504            $popts->setIsPreview( true );
505            $popts->setIsSectionPreview( false );
506            $user = RequestContext::getMain()->getUser();
507            $contentTransformer = MediaWikiServices::getInstance()->getContentTransformer();
508            $content = $contentTransformer->preSaveTransform(
509                $content,
510                $templatetitle,
511                $user,
512                $popts
513            );
514        } else {
515            $templatetitle = null;
516            $content = null;
517        }
518
519        if ( $prefixes || $templatetitle ) {
520            $logic = new Logic( $prefixes, $templatetitle, $content );
521            $resetLogic = $logic->setupForParse( $options );
522            $suppressCache = true;
523
524            $hookContainer = MediaWikiServices::getInstance()->getHookContainer();
525            $resetHook = $hookContainer->scopedRegister( 'ApiParseMakeOutputPage', static function ( $module, $output )
526                use ( $prefixes, $templatetitle, $content )
527            {
528                if ( $prefixes ) {
529                    Logic::addSubpageHandlerToOutput( $prefixes, $output );
530                }
531                if ( $templatetitle ) {
532                    $output->addContentOverride( $templatetitle, $content );
533                }
534            } );
535
536            $reset = new ScopedCallback( static function () use ( &$resetLogic, &$resetHook ) {
537                ScopedCallback::consume( $resetHook );
538                ScopedCallback::consume( $resetLogic );
539            } );
540        }
541    }
542
543    /**
544     * Function that returns an array of valid namespaces to show the page
545     * preview form on for the ResourceLoader
546     *
547     * @param RL\Context $context
548     * @param Config $config
549     * @return array
550     */
551    public static function getResourceLoaderData( $context, $config ) {
552        return array_merge(
553            $config->get( 'TemplateSandboxEditNamespaces' ),
554            ExtensionRegistry::getInstance()->getAttribute( 'TemplateSandboxEditNamespaces' )
555        );
556    }
557
558}