Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 293 |
|
0.00% |
0 / 11 |
CRAP | |
0.00% |
0 / 1 |
SpecialNewsletter | |
0.00% |
0 / 293 |
|
0.00% |
0 / 11 |
2862 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
doesWrites | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
execute | |
0.00% |
0 / 41 |
|
0.00% |
0 / 1 |
72 | |||
getNavigationLinks | |
0.00% |
0 / 39 |
|
0.00% |
0 / 1 |
110 | |||
getHTMLForm | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
doSubscribeExecute | |
0.00% |
0 / 33 |
|
0.00% |
0 / 1 |
6 | |||
submitSubscribeForm | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
20 | |||
doAnnounceExecute | |
0.00% |
0 / 48 |
|
0.00% |
0 / 1 |
20 | |||
submitAnnounceForm | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
72 | |||
doSubscribersExecute | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
20 | |||
submitSubscribersForm | |
0.00% |
0 / 46 |
|
0.00% |
0 / 1 |
110 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\Newsletter\Specials; |
4 | |
5 | use EchoEvent; |
6 | use ExtensionRegistry; |
7 | use HTMLForm; |
8 | use LogEventsList; |
9 | use MediaWiki\Config\ConfigException; |
10 | use MediaWiki\Extension\Newsletter\Newsletter; |
11 | use MediaWiki\Extension\Newsletter\NewsletterStore; |
12 | use MediaWiki\Linker\Linker; |
13 | use MediaWiki\MediaWikiServices; |
14 | use MediaWiki\SpecialPage\SpecialPage; |
15 | use MediaWiki\SpecialPage\UnlistedSpecialPage; |
16 | use MediaWiki\Status\Status; |
17 | use MediaWiki\Title\Title; |
18 | use MediaWiki\User\User; |
19 | use MediaWiki\User\UserArray; |
20 | use RuntimeException; |
21 | use ThrottledError; |
22 | use UserBlockedError; |
23 | |
24 | /** |
25 | * Special page to handle actions related to specific newsletters |
26 | * |
27 | * @author Glaisher |
28 | * @license GPL-2.0-or-later |
29 | */ |
30 | class SpecialNewsletter extends UnlistedSpecialPage { |
31 | |
32 | /** Subpage actions */ |
33 | private const NEWSLETTER_MANAGE = 'manage'; |
34 | private const NEWSLETTER_ANNOUNCE = 'announce'; |
35 | public const NEWSLETTER_SUBSCRIBE = 'subscribe'; |
36 | public const NEWSLETTER_UNSUBSCRIBE = 'unsubscribe'; |
37 | public const NEWSLETTER_SUBSCRIBERS = 'subscribers'; |
38 | |
39 | /** |
40 | * @var Newsletter|null |
41 | */ |
42 | protected $newsletter; |
43 | |
44 | public function __construct() { |
45 | parent::__construct( 'Newsletter' ); |
46 | } |
47 | |
48 | public function doesWrites() { |
49 | return true; |
50 | } |
51 | |
52 | /** |
53 | * @param string|null $par subpage parameter |
54 | */ |
55 | public function execute( $par ) { |
56 | if ( $par == '' ) { |
57 | // If no subpage was specified - only [[Special:Newsletter]] - redirect to Special:Newsletters |
58 | $this->getOutput()->redirect( |
59 | SpecialPage::getTitleFor( 'Newsletters' )->getFullURL(), |
60 | '303' |
61 | ); |
62 | return; |
63 | } |
64 | |
65 | $this->setHeaders(); |
66 | |
67 | // Separate out newsletter id and action from subpage |
68 | $params = explode( '/', $par ); |
69 | $params[1] ??= null; |
70 | [ $id, $action ] = $params; |
71 | |
72 | $out = $this->getOutput(); |
73 | $this->newsletter = Newsletter::newFromID( (int)$id ); |
74 | |
75 | $this->addHelpLink( 'Help:Extension:Newsletter' ); |
76 | |
77 | if ( $this->newsletter ) { |
78 | // Newsletter exists for the given subpage id - let's check what they want to do |
79 | switch ( $action ) { |
80 | case self::NEWSLETTER_SUBSCRIBE: |
81 | case self::NEWSLETTER_UNSUBSCRIBE: |
82 | $this->doSubscribeExecute(); |
83 | break; |
84 | case self::NEWSLETTER_ANNOUNCE: |
85 | $this->doAnnounceExecute(); |
86 | break; |
87 | case self::NEWSLETTER_SUBSCRIBERS: |
88 | $this->doSubscribersExecute(); |
89 | break; |
90 | default: |
91 | $this->getOutput()->redirect( |
92 | Title::makeTitleSafe( NS_NEWSLETTER, $this->newsletter->getName() )->getFullURL() |
93 | ); |
94 | return; |
95 | } |
96 | |
97 | $out->addSubtitle( $this->getNavigationLinks( $action ) ); |
98 | |
99 | } else { |
100 | // Show an error message (with delete log entry) if we couldn't find a newsletter |
101 | $out->showErrorPage( 'newsletter-notfound', 'newsletter-not-found-id' ); |
102 | LogEventsList::showLogExtract( |
103 | $out, |
104 | 'newsletter', |
105 | $this->getPageTitle( $id ), |
106 | '', |
107 | [ |
108 | 'showIfEmpty' => false, |
109 | 'conds' => [ 'log_action' => 'newsletter-removed' ], |
110 | 'msgKey' => 'newsletter-deleted-log' |
111 | ] |
112 | ); |
113 | } |
114 | } |
115 | |
116 | /** |
117 | * Get the navigation links shown in the subtitle |
118 | * |
119 | * @param string|null $current subpage currently being shown, null if default "view" page |
120 | * @return string |
121 | */ |
122 | protected function getNavigationLinks( $current ) { |
123 | $linkRenderer = $this->getLinkRenderer(); |
124 | $listLink = $linkRenderer->makeKnownLink( |
125 | SpecialPage::getTitleFor( 'Newsletters' ), |
126 | $this->msg( 'backlinksubtitle', |
127 | $this->msg( 'newsletter-subtitlelinks-list' )->text() |
128 | )->text() |
129 | ); |
130 | if ( $current === null ) { |
131 | // We've the fancy buttons on the default "view" page so don't |
132 | // add redundant navigation links and fast return here |
133 | return $listLink; |
134 | } |
135 | |
136 | // Build the links taking the current user's access levels into account |
137 | $user = $this->getUser(); |
138 | $actions = []; |
139 | if ( $user->isRegistered() ) { |
140 | $actions[] = $this->newsletter->isSubscribed( $user ) |
141 | ? self::NEWSLETTER_UNSUBSCRIBE |
142 | : self::NEWSLETTER_SUBSCRIBE; |
143 | } |
144 | if ( $this->newsletter->isPublisher( $user ) ) { |
145 | $actions[] = self::NEWSLETTER_ANNOUNCE; |
146 | } |
147 | if ( $this->newsletter->canManage( $user ) ) { |
148 | $actions[] = self::NEWSLETTER_MANAGE; |
149 | $actions[] = self::NEWSLETTER_SUBSCRIBERS; |
150 | } |
151 | |
152 | $links = []; |
153 | foreach ( $actions as $action ) { |
154 | $title = $this->getPageTitle( $this->newsletter->getId() . '/' . $action ); |
155 | // Messages used here: 'newsletter-subtitlelinks-announce', |
156 | // 'newsletter-subtitlelinks-subscribe', 'newsletter-subtitlelinks-unsubscribe' |
157 | // 'newsletter-subtitlelinks-manage' |
158 | $msg = $this->msg( 'newsletter-subtitlelinks-' . $action )->text(); |
159 | $link = $linkRenderer->makeKnownLink( $title, $msg ); |
160 | if ( $action == self::NEWSLETTER_MANAGE ) { |
161 | $title = Title::makeTitleSafe( NS_NEWSLETTER, $this->newsletter->getName() ); |
162 | $msg = $this->msg( 'newsletter-subtitlelinks-' . $action )->text(); |
163 | $link = $linkRenderer->makeKnownLink( $title, $msg, [], [ 'action' => 'edit' ] ); |
164 | } |
165 | if ( $current === $action && $title ) { |
166 | $links[] = Linker::makeSelfLinkObj( $title, htmlspecialchars( $msg ) ); |
167 | } else { |
168 | |
169 | $links[] = $link; |
170 | } |
171 | } |
172 | |
173 | $newsletterLinks = $linkRenderer->makeKnownLink( |
174 | Title::makeTitleSafe( NS_NEWSLETTER, $this->newsletter->getName() ), |
175 | $this->getName() |
176 | ) . ' ' . $this->msg( 'parentheses' ) |
177 | ->rawParams( $this->getLanguage()->pipeList( $links ) ) |
178 | ->parse(); |
179 | |
180 | return $this->getLanguage()->pipeList( [ $listLink, $newsletterLinks ] ); |
181 | } |
182 | |
183 | /** |
184 | * Create a common HTMLForm which can be used by specific page actions |
185 | * |
186 | * @param array $fields array of form fields |
187 | * @param callback $submit submit callback |
188 | * |
189 | * @return HTMLForm |
190 | */ |
191 | private function getHTMLForm( array $fields, /* callable */ $submit ) { |
192 | $form = HTMLForm::factory( |
193 | 'ooui', |
194 | $fields, |
195 | $this->getContext() |
196 | ); |
197 | $form->setSubmitCallback( $submit ); |
198 | |
199 | return $form; |
200 | } |
201 | |
202 | /** |
203 | * Build the (un)subscribe form for Special:Newsletter/$id/(un)subscribe |
204 | * The actual form showed will be switched depending on whether the current |
205 | * user is subscribed or not. |
206 | */ |
207 | protected function doSubscribeExecute() { |
208 | // IPs shouldn't be able to subscribe to newsletters |
209 | $this->requireLogin( 'newsletter-subscribe-loginrequired' ); |
210 | $this->checkReadOnly(); |
211 | $this->getOutput()->setPageTitleMsg( $this->msg( 'newsletter-subscribe' ) ); |
212 | |
213 | if ( $this->newsletter->isSubscribed( $this->getUser() ) ) { |
214 | // User is subscribed so show the unsubscribe form |
215 | $txt = $this->msg( 'newsletter-unsubscribe-text' ) |
216 | ->plaintextParams( $this->newsletter->getName() )->parse(); |
217 | $button = [ |
218 | 'unsubscribe' => [ |
219 | 'type' => 'submit', |
220 | 'name' => 'unsubscribe', |
221 | 'default' => $this->msg( 'newsletter-do-unsubscribe' )->text(), |
222 | 'id' => 'mw-newsletter-unsubscribe', |
223 | 'flags' => [ 'primary', 'destructive' ], |
224 | ] |
225 | ]; |
226 | } else { |
227 | // Show the subscribe form if the user is not subscribed currently |
228 | $txt = $this->msg( 'newsletter-subscribe-text' ) |
229 | ->plaintextParams( $this->newsletter->getName() )->parse(); |
230 | $button = [ |
231 | 'subscribe' => [ |
232 | 'type' => 'submit', |
233 | 'name' => 'subscribe', |
234 | 'default' => $this->msg( 'newsletter-do-subscribe' )->text(), |
235 | 'id' => 'mw-newsletter-subscribe', |
236 | 'flags' => [ 'primary', 'progressive' ], |
237 | ] |
238 | ]; |
239 | } |
240 | |
241 | $form = $this->getHTMLForm( $button, [ $this, 'submitSubscribeForm' ] ); |
242 | $form->addHeaderHtml( $txt ); |
243 | $form->suppressDefaultSubmit(); |
244 | $form->show(); |
245 | $this->getOutput()->addReturnTo( Title::makeTitleSafe( |
246 | NS_NEWSLETTER, $this->newsletter->getName() ) |
247 | ); |
248 | } |
249 | |
250 | /** |
251 | * Submit callback for subscribe form. |
252 | * @return Status |
253 | */ |
254 | public function submitSubscribeForm() { |
255 | $request = $this->getRequest(); |
256 | $user = $this->getUser(); |
257 | |
258 | if ( $request->getCheck( 'subscribe' ) ) { |
259 | $status = $this->newsletter->subscribe( $user ); |
260 | $action = 'subscribe'; |
261 | } elseif ( $request->getCheck( 'unsubscribe' ) ) { |
262 | $status = $this->newsletter->unsubscribe( $user ); |
263 | $action = 'unsubscribe'; |
264 | } else { |
265 | throw new RuntimeException( 'POST data corrupted or required parameter missing from request' ); |
266 | } |
267 | |
268 | if ( $status->isGood() ) { |
269 | // @todo We could probably do this in a better way |
270 | // Add the success message if the action was successful |
271 | // Messages used: 'newsletter-subscribe-success', 'newsletter-unsubscribe-success' |
272 | $this->getOutput()->addHTML( |
273 | $this->msg( "newsletter-$action-success" ) |
274 | ->plaintextParams( $this->newsletter->getName() )->parse() |
275 | ); |
276 | } |
277 | |
278 | return $status; |
279 | } |
280 | |
281 | /** |
282 | * Build the announce form for Special:Newsletter/$id/announce. This does |
283 | * permissions and read-only check as well and handles showing error and |
284 | * success pages. |
285 | * |
286 | * @throws UserBlockedError |
287 | */ |
288 | protected function doAnnounceExecute() { |
289 | $user = $this->getUser(); |
290 | $out = $this->getOutput(); |
291 | |
292 | // Echo handles read-only mode on their own but we'll now let the user know |
293 | // that wiki is currently in read-only mode and stop from here. |
294 | $this->checkReadOnly(); |
295 | |
296 | $block = $user->getBlock(); |
297 | if ( $block ) { |
298 | // Blocked users should just stay blocked. |
299 | throw new UserBlockedError( $block ); |
300 | } |
301 | |
302 | if ( !$this->newsletter->isPublisher( $user ) ) { |
303 | $out->showPermissionsErrorPage( |
304 | [ [ 'newsletter-announce-nopermission' ] ] |
305 | ); |
306 | return; |
307 | } |
308 | |
309 | $out->setPageTitleMsg( |
310 | $this->msg( 'newsletter-announce' ) |
311 | ->plaintextParams( $this->newsletter->getName() ) |
312 | ); |
313 | |
314 | $fields = [ |
315 | 'issuepage' => [ |
316 | 'type' => 'title', |
317 | 'exists' => true, |
318 | 'name' => 'issuepage', |
319 | 'creatable' => true, |
320 | 'required' => true, |
321 | 'autofocus' => true, |
322 | 'label-message' => 'newsletter-announce-issuetitle', |
323 | 'default' => '', |
324 | ], |
325 | 'summary' => [ |
326 | // @todo add a help message explaining what this does |
327 | 'type' => 'text', |
328 | 'name' => 'summary', |
329 | 'label-message' => 'newsletter-announce-summary', |
330 | 'maxlength' => '160', |
331 | 'required' => true, |
332 | ], |
333 | ]; |
334 | |
335 | $form = $this->getHTMLForm( |
336 | $fields, |
337 | [ $this, 'submitAnnounceForm' ] |
338 | ); |
339 | $form->setSubmitTextMsg( 'newsletter-announce-submit' ); |
340 | |
341 | $status = $form->show(); |
342 | if ( $status === true ) { |
343 | // Success! |
344 | $out->addHTML( |
345 | $this->msg( 'newsletter-announce-success' ) |
346 | ->plaintextParams( $this->newsletter->getName() ) |
347 | ->numParams( $this->newsletter->getSubscribersCount() ) |
348 | ->parseAsBlock() |
349 | ); |
350 | $out->addReturnTo( Title::makeTitleSafe( NS_NEWSLETTER, $this->newsletter->getName() ) ); |
351 | } |
352 | } |
353 | |
354 | /** |
355 | * Submit callback for the announce form (validate, add to issues table and create |
356 | * Echo event). This assumes that permissions check etc has been done already. |
357 | * The method is only called if the Echo extension is installed. |
358 | * |
359 | * @param array $data |
360 | * |
361 | * @return Status|bool true on success, Status fatal otherwise |
362 | */ |
363 | public function submitAnnounceForm( array $data ) { |
364 | $title = Title::newFromText( $data['issuepage'] ); |
365 | |
366 | // Do some basic validation on the issue page |
367 | if ( !$title ) { |
368 | return Status::newFatal( 'newsletter-announce-invalid-page' ); |
369 | } |
370 | |
371 | if ( !$title->exists() ) { |
372 | return Status::newFatal( 'newsletter-announce-nonexistent-page' ); |
373 | } |
374 | |
375 | if ( $title->inNamespace( NS_FILE ) ) { |
376 | // Eh.. |
377 | return Status::newFatal( 'newsletter-announce-invalid-page' ); |
378 | } |
379 | |
380 | // Validate summary |
381 | $reasonSpamMatch = MediaWikiServices::getInstance() |
382 | ->getSpamChecker() |
383 | ->checkSummary( $data['summary'] ); |
384 | if ( $reasonSpamMatch ) { |
385 | return Status::newFatal( 'spamprotectionmatch', $reasonSpamMatch ); |
386 | } |
387 | |
388 | if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) { |
389 | throw new ConfigException( 'Echo extension is not installed.' ); |
390 | } |
391 | |
392 | $user = $this->getUser(); |
393 | if ( $user->pingLimiter( 'newsletter-announce' ) ) { |
394 | // Prevent people from spamming |
395 | throw new ThrottledError; |
396 | } |
397 | |
398 | $summary = trim( $data['summary'] ); |
399 | |
400 | // Everything seems okay. Let's try to do it for real now. |
401 | $store = NewsletterStore::getDefaultInstance(); |
402 | $success = $store->addNewsletterIssue( $this->newsletter, $title, $user, $summary ); |
403 | |
404 | if ( !$success ) { |
405 | // DB insert failed. :( so don't create an Echo event and stop from here |
406 | return Status::newFatal( 'newsletter-announce-failure' ); |
407 | } |
408 | |
409 | EchoEvent::create( |
410 | [ |
411 | 'type' => 'newsletter-announce', |
412 | 'title' => $title, |
413 | 'extra' => [ |
414 | 'newsletter-name' => $this->newsletter->getName(), |
415 | 'newsletter-id' => $this->newsletter->getId(), |
416 | 'section-text' => $summary, |
417 | ], |
418 | 'agent' => $user, |
419 | ] |
420 | ); |
421 | |
422 | // Yay! |
423 | return true; |
424 | } |
425 | |
426 | /** |
427 | * Build the form for displaying the subscribers to a newsletter. This includes |
428 | * a permission check, and then lists them all in a textarea. |
429 | */ |
430 | protected function doSubscribersExecute() { |
431 | $user = $this->getUser(); |
432 | $out = $this->getOutput(); |
433 | |
434 | if ( !$this->newsletter->canManage( $user ) ) { |
435 | $out->showPermissionsErrorPage( |
436 | [ [ 'newsletter-subscribers-nopermission' ] ] |
437 | ); |
438 | return; |
439 | } |
440 | |
441 | $out->setPageTitle( $this->msg( 'newsletter-subscribers' )->text() ); |
442 | $subscribers = UserArray::newFromIDs( $this->newsletter->getSubscribers() ); |
443 | $subscribersNames = []; |
444 | foreach ( $subscribers as $subscriber ) { |
445 | $subscribersNames[] = $subscriber->getName(); |
446 | } |
447 | |
448 | natcasesort( $subscribersNames ); |
449 | |
450 | $fields = [ |
451 | 'subscribers' => [ |
452 | 'type' => 'textarea', |
453 | 'raw' => true, |
454 | 'rows' => 10, |
455 | 'default' => implode( "\n", $subscribersNames ) |
456 | ], |
457 | ]; |
458 | |
459 | $form = $this->getHTMLForm( |
460 | $fields, |
461 | [ $this, 'submitSubscribersForm' ] |
462 | ); |
463 | if ( $form->show() ) { |
464 | $out->addReturnTo( Title::makeTitleSafe( NS_NEWSLETTER, $this->newsletter->getName() ) ); |
465 | } |
466 | } |
467 | |
468 | /** |
469 | * Submit callback for the subscribers form (validate, edit subscribers table). |
470 | * This assumes that permissions check etc has been done already. |
471 | * The method is only called if the Echo extension is installed. |
472 | * |
473 | * @param array $data |
474 | * |
475 | * @return Status|bool true on success, Status fatal otherwise |
476 | */ |
477 | public function submitSubscribersForm( array $data ) { |
478 | $subscriberNames = explode( "\n", $data['subscribers'] ); |
479 | // Strip whitespace, then remove blank lines and duplicates |
480 | $subscriberNames = array_unique( array_filter( array_map( 'trim', $subscriberNames ) ) ); |
481 | |
482 | $oldSubscribersIds = $this->newsletter->getSubscribers(); |
483 | $newSubscribersIds = []; |
484 | foreach ( $subscriberNames as $subscriberName ) { |
485 | $user = User::newFromName( $subscriberName ); |
486 | |
487 | if ( !$user || !$user->getId() ) { |
488 | // Input contains an invalid username |
489 | return Status::newFatal( 'newsletter-subscribers-invalid', $subscriberName ); |
490 | } |
491 | |
492 | $newSubscribersIds[] = $user->getId(); |
493 | |
494 | } |
495 | |
496 | // Do the actual modifications now |
497 | $added = array_diff( $newSubscribersIds, $oldSubscribersIds ); |
498 | $removed = array_diff( $oldSubscribersIds, $newSubscribersIds ); |
499 | |
500 | $store = NewsletterStore::getDefaultInstance(); |
501 | $store->addSubscription( $this->newsletter, $added ); |
502 | if ( $removed ) { |
503 | $store->removeSubscription( $this->newsletter, $removed ); |
504 | } |
505 | $out = $this->getOutput(); |
506 | // Now report to the user |
507 | if ( $added || $removed ) { |
508 | if ( !ExtensionRegistry::getInstance()->isLoaded( 'Echo' ) ) { |
509 | throw new ConfigException( 'Echo extension is not installed.' ); |
510 | } |
511 | if ( $added ) { |
512 | EchoEvent::create( |
513 | [ |
514 | 'type' => 'newsletter-subscribed', |
515 | 'extra' => [ |
516 | 'newsletter-name' => $this->newsletter->getName(), |
517 | 'new-subscribers-id' => $added, |
518 | 'newsletter-id' => $this->newsletter->getId() |
519 | ], |
520 | 'agent' => $this->getUser() |
521 | ] |
522 | ); |
523 | } |
524 | if ( $removed ) { |
525 | EchoEvent::create( |
526 | [ |
527 | 'type' => 'newsletter-unsubscribed', |
528 | 'extra' => [ |
529 | 'newsletter-name' => $this->newsletter->getName(), |
530 | 'removed-subscribers-id' => $removed, |
531 | 'newsletter-id' => $this->newsletter->getId() |
532 | ], |
533 | 'agent' => $this->getUser() |
534 | ] |
535 | ); |
536 | } |
537 | $out->addWikiMsg( 'newsletter-edit-subscribers-success' ); |
538 | } else { |
539 | // Submitted without any changes to the existing subscribers |
540 | $out->addWikiMsg( 'newsletter-edit-subscribers-nochanges' ); |
541 | } |
542 | return true; |
543 | } |
544 | } |