Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 293
0.00% covered (danger)
0.00%
0 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialNewsletter
0.00% covered (danger)
0.00%
0 / 293
0.00% covered (danger)
0.00%
0 / 11
2862
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
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 / 41
0.00% covered (danger)
0.00%
0 / 1
72
 getNavigationLinks
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
110
 getHTMLForm
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 doSubscribeExecute
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
6
 submitSubscribeForm
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 doAnnounceExecute
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
20
 submitAnnounceForm
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
72
 doSubscribersExecute
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
20
 submitSubscribersForm
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
110
1<?php
2
3namespace MediaWiki\Extension\Newsletter\Specials;
4
5use EchoEvent;
6use ExtensionRegistry;
7use HTMLForm;
8use LogEventsList;
9use MediaWiki\Config\ConfigException;
10use MediaWiki\Extension\Newsletter\Newsletter;
11use MediaWiki\Extension\Newsletter\NewsletterStore;
12use MediaWiki\Linker\Linker;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\SpecialPage\SpecialPage;
15use MediaWiki\SpecialPage\UnlistedSpecialPage;
16use MediaWiki\Status\Status;
17use MediaWiki\Title\Title;
18use MediaWiki\User\User;
19use MediaWiki\User\UserArray;
20use RuntimeException;
21use ThrottledError;
22use 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 */
30class 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}