Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
9.80% covered (danger)
9.80%
30 / 306
6.67% covered (danger)
6.67%
1 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialMassMessage
9.80% covered (danger)
9.80%
30 / 306
6.67% covered (danger)
6.67%
1 / 15
1598.67
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 execute
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 alterForm
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 getDisplayFormat
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onSuccess
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 getState
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getStatus
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getCount
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getFormFields
0.00% covered (danger)
0.00%
0 / 99
0.00% covered (danger)
0.00%
0 / 1
20
 onSubmit
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
20
 getUnclosedTags
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
11
 preview
0.00% covered (danger)
0.00%
0 / 103
0.00% covered (danger)
0.00%
0 / 1
72
 showPreviewInfo
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 getLabeledSections
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace MediaWiki\MassMessage\Specials;
4
5use MediaWiki\Content\ContentHandler;
6use MediaWiki\Content\TextContent;
7use MediaWiki\EditPage\EditPage;
8use MediaWiki\Html\Html;
9use MediaWiki\HTMLForm\HTMLForm;
10use MediaWiki\MassMessage\Lookup\SpamlistLookup;
11use MediaWiki\MassMessage\MassMessage;
12use MediaWiki\MassMessage\MessageBuilder;
13use MediaWiki\MassMessage\MessageContentFetcher\LabeledSectionContentFetcher;
14use MediaWiki\MassMessage\MessageContentFetcher\LocalMessageContentFetcher;
15use MediaWiki\MassMessage\PageMessage\PageMessageBuilder;
16use MediaWiki\MassMessage\RequestProcessing\MassMessageRequest;
17use MediaWiki\MassMessage\RequestProcessing\MassMessageRequestParser;
18use MediaWiki\MediaWikiServices;
19use MediaWiki\Message\Message;
20use MediaWiki\Parser\ParserOutputLinkTypes;
21use MediaWiki\Parser\Parsoid\LintErrorChecker;
22use MediaWiki\SpecialPage\FormSpecialPage;
23use MediaWiki\Status\Status;
24use MediaWiki\Title\Title;
25use MediaWiki\WikiMap\WikiMap;
26use OOUI\FieldsetLayout;
27use OOUI\HtmlSnippet;
28use OOUI\PanelLayout;
29use OOUI\Widget;
30
31/**
32 * Form to allow users to send messages to a lot of users at once.
33 *
34 * @author Kunal Mehta
35 * @license GPL-2.0-or-later
36 */
37
38class SpecialMassMessage extends FormSpecialPage {
39    /** @var Status */
40    protected $status;
41    /** @var string */
42    protected $state;
43    /** @var int */
44    protected $count;
45    /** @var LocalMessageContentFetcher */
46    private $localMessageContentFetcher;
47    /** @var LabeledSectionContentFetcher */
48    private $labeledSectionContentFetcher;
49    /** @var MessageBuilder */
50    private $messageBuilder;
51    /** @var PageMessageBuilder */
52    private $pageMessageBuilder;
53
54    private LintErrorChecker $lintErrorChecker;
55
56    /**
57     * @param LabeledSectionContentFetcher $labeledSectionContentFetcher
58     * @param LocalMessageContentFetcher $localMessageContentFetcher
59     * @param PageMessageBuilder $pageMessageBuilder
60     * @param LintErrorChecker $lintErrorChecker
61     */
62    public function __construct(
63        LabeledSectionContentFetcher $labeledSectionContentFetcher,
64        LocalMessageContentFetcher $localMessageContentFetcher,
65        PageMessageBuilder $pageMessageBuilder,
66        LintErrorChecker $lintErrorChecker
67    ) {
68        parent::__construct( 'MassMessage', 'massmessage' );
69        $this->labeledSectionContentFetcher = $labeledSectionContentFetcher;
70        $this->localMessageContentFetcher = $localMessageContentFetcher;
71        $this->messageBuilder = new MessageBuilder();
72        $this->pageMessageBuilder = $pageMessageBuilder;
73        $this->lintErrorChecker = $lintErrorChecker;
74    }
75
76    public function doesWrites() {
77        return true;
78    }
79
80    /** @inheritDoc */
81    public function execute( $par ) {
82        $request = $this->getRequest();
83        $output = $this->getOutput();
84
85        $this->addHelpLink( 'Help:Extension:MassMessage' );
86
87        $output->addModules( 'ext.MassMessage.special.js' );
88        $output->addModuleStyles( 'ext.MassMessage.styles' );
89
90        // Some variables...
91        $this->status = new Status();
92
93        // Figure out what state we're in.
94        if ( $request->getCheck( 'submit-button' ) ) {
95            $this->state = 'submit';
96        } elseif ( $request->getCheck( 'preview-button' ) ) {
97            $this->state = 'preview';
98        } else {
99            $this->state = 'form';
100        }
101
102        parent::execute( $par );
103    }
104
105    /** @inheritDoc */
106    protected function alterForm( HTMLForm $form ) {
107        if ( $this->state === 'form' ) {
108            $form->addPreHtml( $this->msg( 'massmessage-form-header' )->parse() );
109        }
110        return $form
111            ->setId( 'mw-massmessage-form' )
112            ->setWrapperLegendMsg( 'massmessage' )
113            // We use our own buttons, so supress the default one.
114            ->suppressDefaultSubmit()
115            ->setMethod( 'post' );
116    }
117
118    /** @inheritDoc */
119    protected function getDisplayFormat() {
120        return 'ooui';
121    }
122
123    /** @inheritDoc */
124    public function onSuccess() {
125        if ( $this->state === 'submit' ) {
126            $output = $this->getOutput();
127            $output->addWikiMsg(
128                'massmessage-submitted',
129                Message::numParam( $this->count )
130            );
131            $output->addWikiMsg( 'massmessage-nextsteps' );
132        }
133    }
134
135    /** @return string */
136    public function getState() {
137        return $this->state;
138    }
139
140    /** @return Status */
141    public function getStatus() {
142        return $this->status;
143    }
144
145    /**
146     * Note that this won't be initalized unless submit is called.
147     *
148     * @return int
149     */
150    public function getCount() {
151        return $this->count;
152    }
153
154    /** @inheritDoc */
155    protected function getFormFields() {
156        $request = $this->getRequest();
157        $controlTabIndex = 1;
158
159        $isPreview = $this->state === 'preview';
160
161        $m = [];
162        // Who to send to
163        $m['spamlist'] = [
164            'id' => 'mw-massmessage-form-spamlist',
165            'name' => 'spamlist',
166            'type' => 'title',
167            'tabindex' => $controlTabIndex++,
168            'label-message' => 'massmessage-form-spamlist',
169            'default' => $request->getText( 'spamlist' )
170        ];
171        // The subject line
172        $m['subject'] = [
173            'id' => 'mw-massmessage-form-subject',
174            'name' => 'subject',
175            'type' => 'text',
176            'tabindex' => $controlTabIndex++,
177            'label-message' => 'massmessage-form-subject',
178            'default' => $request->getText( 'subject' ),
179            'help-message' => 'massmessage-form-subject-help',
180            'maxlength' => 240
181        ];
182
183        // The page to sent as message
184        $m['page-message'] = [
185            'id' => 'mw-massmessage-form-page',
186            'name' => 'page-message',
187            'type' => 'title',
188            'tabindex' => $controlTabIndex++,
189            'label-message' => 'massmessage-form-page',
190            'default' => $request->getText( 'page-message' ),
191            'help-message' => 'massmessage-form-page-help',
192            'required' => false
193        ];
194
195        $options = [ '----' => '' ];
196        $pagename = $request->getText( 'page-message' );
197        if ( trim( $pagename ) !== '' ) {
198            $sections = $this->getLabeledSections( $pagename );
199            $options += array_combine( $sections, $sections );
200        }
201
202        $m['page-subject-section'] = [
203            'id' => 'mw-massmessage-form-page-subject-section',
204            'name' => 'page-subject-section',
205            'type' => 'select',
206            'options' => $options,
207            'tabindex' => $controlTabIndex++,
208            'disabled' => !$isPreview,
209            'label-message' => 'massmessage-form-page-subject-section',
210            'default' => $request->getText( 'page-subject-section' ),
211            'help-message' => 'massmessage-form-page-subject-section-help',
212        ];
213
214        $m['page-message-section'] = [
215            'id' => 'mw-massmessage-form-page-section',
216            'name' => 'page-message-section',
217            'type' => 'select',
218            'options' => $options,
219            'tabindex' => $controlTabIndex++,
220            'disabled' => !$isPreview,
221            'label-message' => 'massmessage-form-page-message-section',
222            'default' => $request->getText( 'page-message-section' ),
223            'help-message' => 'massmessage-form-page-message-section-help',
224        ];
225
226        // The message to send
227        $m['message'] = [
228            'id' => 'mw-massmessage-form-message',
229            'name' => 'message',
230            'type' => 'textarea',
231            'tabindex' => $controlTabIndex++,
232            'label-message' => 'massmessage-form-message',
233            'default' => $request->getText( 'message' )
234        ];
235
236        // If we're previewing a message and there are no errors, show the copyright warning and
237        // the submit button.
238        if ( $isPreview ) {
239            $requestParser = new MassMessageRequestParser();
240            $data = [
241                'spamlist' => $request->getText( 'spamlist' ),
242                'subject' => $request->getText( 'subject' ),
243                'page-message' => $request->getText( 'page-message' ),
244                'page-message-section' => $request->getText( 'page-message-section' ),
245                'page-subject-section' => $request->getText( 'page-subject-section' ),
246                'message' => $request->getText( 'message' )
247            ];
248            $status = $requestParser->parseRequest( $data, $this->getUser() );
249            if ( $status->isOK() ) {
250                $m['message']['help'] = EditPage::getCopyrightWarning(
251                    $this->getPageTitle( false ),
252                    'parse',
253                    $this
254                );
255                $m['submit-button'] = [
256                    'id' => 'mw-massmessage-form-submit-button',
257                    'name' => 'submit-button',
258                    'type' => 'submit',
259                    'tabindex' => $controlTabIndex++,
260                    'default' => $this->msg( 'massmessage-form-submit' )->text()
261                ];
262            }
263        }
264
265        $m['preview-button'] = [
266            'id' => 'mw-massmessage-form-preview-button',
267            'name' => 'preview-button',
268            'type' => 'submit',
269            'tabindex' => $controlTabIndex++,
270            'default' => $this->msg( 'massmessage-form-preview' )->text()
271        ];
272
273        return $m;
274    }
275
276    /**
277     * Callback function.
278     * Does some basic verification of data.
279     * Decides whether to show the preview screen or the submitted message.
280     *
281     * @inheritDoc
282     * @return Status|bool
283     */
284    public function onSubmit( array $data ) {
285        $requestParser = new MassMessageRequestParser();
286        $this->status = $requestParser->parseRequest( $data, $this->getUser() );
287
288        // Die on errors.
289        if ( !$this->status->isOK() ) {
290            $this->state = 'form';
291            return $this->status;
292        }
293
294        if ( $this->state === 'submit' ) {
295            $this->count = MassMessage::submit( $this->getUser(), $this->status->getValue() );
296            return $this->status;
297        } else {
298            // $this->state can only be 'preview' here
299            $this->preview( $this->status->getValue() );
300
301            // Die on errors.
302            if ( !$this->status->isOK() ) {
303                $this->state = 'form';
304                return $this->status;
305            }
306
307            // No submission attempted
308            return false;
309        }
310    }
311
312    /**
313     * Returns an array containing possibly unclosed HTML tags in $message.
314     *
315     * TODO: Use an HTML parser instead of regular expressions
316     *
317     * @param string $message
318     * @return string[]
319     */
320    protected function getUnclosedTags( $message ) {
321        // For start tags, ignore ones that contain '/' (assume those are self-closing).
322        preg_match_all( '|\<([\w]+)[^/]*?>|', $message, $startTags );
323        preg_match_all( '|\</([\w]+)|', $message, $endTags );
324
325        // Keep just the element names from the matched patterns.
326        $startTags = $startTags[1] ?? [];
327        $endTags = $endTags[1] ?? [];
328
329        // Stop and return an empty array if there are no HTML tags.
330        if ( !$startTags && !$endTags ) {
331            return [];
332        }
333
334        // Construct a set containing elements that do not need an end tag.
335        // List obtained from http://www.w3.org/TR/html-markup/syntax.html#syntax-elements
336        $voidElements = array_flip( [ 'area', 'base', 'br', 'col', 'command', 'embed', 'hr', 'img',
337            'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr' ] );
338
339        // Count start / end tags for each element, ignoring start tags of void elements.
340        $tags = [];
341        foreach ( $startTags as $tag ) {
342            if ( !isset( $voidElements[$tag] ) ) {
343                if ( !isset( $tags[$tag] ) ) {
344                    $tags[$tag] = 1;
345                } else {
346                    $tags[$tag]++;
347                }
348            }
349        }
350        foreach ( $endTags as $tag ) {
351            if ( !isset( $tags[$tag] ) ) {
352                $tags[$tag] = -1;
353            } else {
354                $tags[$tag]--;
355            }
356        }
357
358        $results = [];
359        foreach ( $tags as $element => $num ) {
360            if ( $num > 0 ) {
361                $results[] = '<' . $element . '>';
362            } elseif ( $num < 0 ) {
363                $results[] = '</' . $element . '>';
364            }
365        }
366        return $results;
367    }
368
369    /**
370     * A preview/confirmation screen.
371     * The preview generation code was hacked up from EditPage.php.
372     *
373     * @param MassMessageRequest $request
374     */
375    protected function preview( MassMessageRequest $request ) {
376        $this->getOutput()->addWikiMsg( 'massmessage-just-preview' );
377
378        // Output the number of recipients
379        $targets = SpamlistLookup::getTargets( $request->getSpamList() );
380        $infoMessages = [
381            $this->msg( 'massmessage-preview-count' )->numParams( count( $targets ) )->parse()
382        ];
383
384        $pageMessage = null;
385        $pageSubject = null;
386        if ( $request->hasPageMessage() ) {
387            $pageTitle = $this->localMessageContentFetcher
388                ->getTitle( $request->getPageMessage() )
389                ->getValue();
390
391            if ( MassMessage::isSourceTranslationPage( $pageTitle ) ) {
392                $infoMessages[] = $this->msg( 'massmessage-translate-page-info' )->parse();
393            }
394
395            $pageMessageBuilderResult = $this->pageMessageBuilder->getContent(
396                $pageTitle,
397                $request->getPageMessageSection(),
398                $request->getPageSubjectSection(),
399                WikiMap::getCurrentWikiId()
400            );
401
402            if ( $pageMessageBuilderResult->isOK() ) {
403                $pageMessage = $pageMessageBuilderResult->getPageMessage();
404                $pageSubject = $pageMessageBuilderResult->getPageSubject();
405            }
406        }
407
408        $this->showPreviewInfo( $infoMessages );
409
410        $messageText = $this->messageBuilder->buildMessage(
411            $request->getMessage(),
412            $pageMessage,
413            // This forces language wrapping always. Good for clarity
414            null,
415            $request->getComment()
416        );
417
418        $subjectText = $this->messageBuilder->buildSubject(
419            $request->getSubject(),
420            $pageSubject,
421            // This forces language wrapping always. Good for clarity
422            null
423        );
424
425        // Use a mock target as the context for rendering the preview
426        $mockTarget = Title::makeTitle( NS_PROJECT, 'MassMessage:A page that should not exist' );
427        $services = MediaWikiServices::getInstance();
428        $wikipage = $services->getWikiPageFactory()->newFromTitle( $mockTarget );
429
430        // Convert into a content object
431        $content = ContentHandler::makeContent( $messageText, $mockTarget );
432        // Parser stuff. Taken from EditPage::getPreviewText()
433        $parserOptions = $wikipage->makeParserOptions( $this->getContext() );
434        $parserOptions->setIsPreview( true );
435        $parserOptions->setIsSectionPreview( false );
436        $content = $content->addSectionHeader( $subjectText );
437
438        // Hooks not being run: EditPageGetPreviewContent, EditPageGetPreviewText
439        $contentTransformer = $services->getContentTransformer();
440        $content = $contentTransformer->preSaveTransform(
441            $content,
442            $mockTarget,
443            MassMessage::getMessengerUser(),
444            $parserOptions
445        );
446        $contentRenderer = $services->getContentRenderer();
447        $parserOutput = $contentRenderer->getParserOutput( $content, $mockTarget, null, $parserOptions );
448        $previewLayout = new PanelLayout( [
449            'content' => new FieldsetLayout( [
450                'label' => $this->msg( 'massmessage-fieldset-preview' )->text(),
451                'items' => [
452                    new Widget( [
453                        'content' => new HtmlSnippet(
454                            $parserOutput->runOutputPipeline( $parserOptions, [ 'enableSectionEditLinks' => false ] )
455                                ->getContentHolderText()
456                        ),
457                    ] ),
458                ],
459            ] ),
460            'expanded' => false,
461            'framed' => true,
462            'padded' => true,
463        ] );
464        $this->getOutput()->addHTML( $previewLayout );
465
466        $wikitextPreviewLayout = new PanelLayout( [
467            'content' => [
468                new FieldsetLayout( [
469                    'label' => $this->msg( 'massmessage-fieldset-wikitext-preview' )->text(),
470                    'items' => [
471                        new Widget( [
472                            'content' => new HtmlSnippet(
473                                // @phan-suppress-next-next-line SecurityCheck-DoubleEscaped
474                                // Intentionally including escaped HTML tags in the output
475                                Html::element( 'pre', [], "== {$subjectText} ==\n\n$messageText" )
476                            ),
477                        ] ),
478                    ],
479                ] ),
480            ],
481            'expanded' => false,
482            'framed' => true,
483            'padded' => true,
484        ] );
485        $this->getOutput()->addHTML( $wikitextPreviewLayout );
486
487        // Check if we have unescaped langlinks (T56846)
488        if ( $parserOutput->getLinkList( ParserOutputLinkTypes::LANGUAGE ) ) {
489            $this->status->fatal( 'massmessage-unescaped-langlinks' );
490        }
491
492        // Check for unclosed HTML tags (T56909)
493        // TODO: This probably redundant with the Linter error "missing-end-tag"
494        // and can be removed.
495        $unclosedTags = $this->getUnclosedTags( $request->getMessage() );
496        if ( $unclosedTags ) {
497            $this->status->fatal(
498                $this->msg( 'massmessage-badhtml' )
499                    ->params( $this->getLanguage()->commaList(
500                        array_map( 'htmlspecialchars', $unclosedTags )
501                    ) )
502                    ->numParams( count( $unclosedTags ) )
503            );
504        }
505
506        /** @var TextContent $content */
507        '@phan-var TextContent $content';
508        // Check for no timestamp (T56848)
509        if ( !preg_match( MassMessage::getTimestampRegex(), $content->getText() ) ) {
510            $this->status->fatal( 'massmessage-no-timestamp' );
511        }
512
513        // Check for Linter errors (T358818)
514        $lintErrors = $this->lintErrorChecker->check( $request->getMessage() );
515        foreach ( $lintErrors as $e ) {
516            $msg = $this->msg( "linterror-{$e['type']}" )->parse();
517            $this->status->fatal( 'massmessage-linter-error', $msg );
518        }
519    }
520
521    /**
522     * @param array $infoMessages
523     */
524    protected function showPreviewInfo( array $infoMessages ) {
525        $infoListHtml = $infoMessages[0];
526        if ( count( $infoMessages ) > 1 ) {
527            $infoListHtml = '<ul>';
528            foreach ( $infoMessages as $info ) {
529                $infoListHtml .= '<li>' . $info . '</li>';
530            }
531            $infoListHtml .= '</ul>';
532        }
533
534        $infoLayout = new PanelLayout( [
535            'content' => new FieldsetLayout( [
536                'label' => $this->msg( 'massmessage-fieldset-info' )->text(),
537                'items' => [
538                    new Widget( [
539                        'content' => new HtmlSnippet( $infoListHtml )
540                    ] ),
541                ],
542            ] ),
543            'expanded' => false,
544            'framed' => true,
545            'padded' => true,
546        ] );
547
548        $this->getOutput()->addHTML( $infoLayout );
549    }
550
551    /**
552     * Get sections given a page name.
553     *
554     * @param string $pagename
555     * @return string[]
556     */
557    private function getLabeledSections( string $pagename ): array {
558        $pageTitle = Title::newFromText( $pagename );
559        if ( $pageTitle ) {
560            $status = $this->localMessageContentFetcher->getContent( $pageTitle );
561            if ( $status->isOK() ) {
562                return $this->labeledSectionContentFetcher->getSections(
563                    $status->getValue()->getWikitext()
564                );
565            }
566        }
567
568        return [];
569    }
570}