Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 297 |
|
0.00% |
0 / 15 |
CRAP | |
0.00% |
0 / 1 |
SpecialMassMessage | |
0.00% |
0 / 297 |
|
0.00% |
0 / 15 |
1980 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
doesWrites | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
12 | |||
alterForm | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
getDisplayFormat | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
onSuccess | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
getState | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getStatus | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getCount | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getFormFields | |
0.00% |
0 / 99 |
|
0.00% |
0 / 1 |
20 | |||
onSubmit | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
12 | |||
getUnclosedTags | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
132 | |||
preview | |
0.00% |
0 / 99 |
|
0.00% |
0 / 1 |
56 | |||
showPreviewInfo | |
0.00% |
0 / 20 |
|
0.00% |
0 / 1 |
12 | |||
getLabeledSections | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | |
3 | namespace MediaWiki\MassMessage\Specials; |
4 | |
5 | use ContentHandler; |
6 | use HTMLForm; |
7 | use MediaWiki\EditPage\EditPage; |
8 | use MediaWiki\Html\Html; |
9 | use MediaWiki\MassMessage\Lookup\SpamlistLookup; |
10 | use MediaWiki\MassMessage\MassMessage; |
11 | use MediaWiki\MassMessage\MessageBuilder; |
12 | use MediaWiki\MassMessage\MessageContentFetcher\LabeledSectionContentFetcher; |
13 | use MediaWiki\MassMessage\MessageContentFetcher\LocalMessageContentFetcher; |
14 | use MediaWiki\MassMessage\PageMessage\PageMessageBuilder; |
15 | use MediaWiki\MassMessage\RequestProcessing\MassMessageRequest; |
16 | use MediaWiki\MassMessage\RequestProcessing\MassMessageRequestParser; |
17 | use MediaWiki\MediaWikiServices; |
18 | use MediaWiki\SpecialPage\FormSpecialPage; |
19 | use MediaWiki\Status\Status; |
20 | use MediaWiki\Title\Title; |
21 | use MediaWiki\WikiMap\WikiMap; |
22 | use Message; |
23 | use OOUI\FieldsetLayout; |
24 | use OOUI\HtmlSnippet; |
25 | use OOUI\PanelLayout; |
26 | use OOUI\Widget; |
27 | use 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 | |
36 | class 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 | } |