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