Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 331
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialContact
0.00% covered (danger)
0.00%
0 / 331
0.00% covered (danger)
0.00%
0 / 10
7832
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTypeConfig
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 getFormSpecificMessageKey
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 execute
0.00% covered (danger)
0.00%
0 / 156
0.00% covered (danger)
0.00%
0 / 1
1056
 processInput
0.00% covered (danger)
0.00%
0 / 135
0.00% covered (danger)
0.00%
0 / 1
1406
 getYesOrNoMsg
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 useCaptcha
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 getCaptcha
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getContactPageHookRunner
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * Speclial:Contact, a contact form for visitors.
4 * Based on SpecialEmailUser.php
5 *
6 * @file
7 * @ingroup SpecialPage
8 * @author Daniel Kinzler, brightbyte.de
9 * @copyright © 2007-2014 Daniel Kinzler, Sam Reed
10 * @license GPL-2.0-or-later
11 */
12
13namespace MediaWiki\Extension\ContactPage;
14
15use ErrorPageError;
16use ExtensionRegistry;
17use HTMLForm;
18use MailAddress;
19use MediaWiki\Extension\ConfirmEdit\Hooks as ConfirmEditHooks;
20use MediaWiki\Extension\ContactPage\Hooks\HookRunner;
21use MediaWiki\Html\Html;
22use MediaWiki\Parser\Sanitizer;
23use MediaWiki\Session\SessionManager;
24use MediaWiki\SpecialPage\UnlistedSpecialPage;
25use MediaWiki\Status\Status;
26use MediaWiki\User\Options\UserOptionsLookup;
27use MediaWiki\User\User;
28use UserBlockedError;
29use UserMailer;
30
31/**
32 * Provides the contact form
33 * @ingroup SpecialPage
34 */
35class SpecialContact extends UnlistedSpecialPage {
36    /** @var UserOptionsLookup */
37    private $userOptionsLookup;
38    /** @var HookRunner|null */
39    private $contactPageHookRunner;
40
41    /**
42     * @param UserOptionsLookup $userOptionsLookup
43     */
44    public function __construct( UserOptionsLookup $userOptionsLookup ) {
45        parent::__construct( 'Contact' );
46        $this->userOptionsLookup = $userOptionsLookup;
47    }
48
49    /**
50     * @inheritDoc
51     */
52    public function getDescription() {
53        return $this->msg( 'contactpage' );
54    }
55
56    /**
57     * @var string
58     */
59    protected $formType;
60
61    /**
62     * @return array
63     */
64    protected function getTypeConfig() {
65        $contactConfig = $this->getConfig()->get( 'ContactConfig' );
66
67        if ( $contactConfig['default']['SenderName'] === null ) {
68            $sitename = $this->getConfig()->get( 'Sitename' );
69            $contactConfig['default']['SenderName'] = "Contact Form on $sitename";
70        }
71
72        if ( isset( $contactConfig[$this->formType] ) ) {
73            return $contactConfig[$this->formType] + $contactConfig['default'];
74        }
75        return $contactConfig['default'];
76    }
77
78    /**
79     * Helper function for ::execute that returns a form
80     * specific message key if it is is not disabled.
81     * Otherwise returns the generic message key.
82     * Used to make it possible for forms to have
83     * form-specific messages.
84     *
85     * @param string $genericMessageKey The message key that will be used if no form-specific one can be used
86     * @return string
87     */
88    protected function getFormSpecificMessageKey( string $genericMessageKey ) {
89        $formSpecificMessageKey = $genericMessageKey . '-' . $this->formType;
90        if ( !str_starts_with( $genericMessageKey, 'contactpage' ) ) {
91            // If the generic message does not start with "contactpage" the form
92            //  specific one will have "contactpage-" prefixed on the generic message
93            //  name.
94            $formSpecificMessageKey = 'contactpage-' . $formSpecificMessageKey;
95        }
96        if ( $this->formType && !$this->msg( $formSpecificMessageKey )->isDisabled() ) {
97            // Return the form-specific message if the form type is not the empty string
98            //  and the message is defined.
99            return $formSpecificMessageKey;
100        }
101        return $genericMessageKey;
102    }
103
104    /**
105     * Main execution function
106     *
107     * @param string|null $par Parameters passed to the page
108     * @throws UserBlockedError
109     * @throws ErrorPageError
110     */
111    public function execute( $par ) {
112        if ( !$this->getConfig()->get( 'EnableEmail' ) ) {
113            // From Special:EmailUser
114            throw new ErrorPageError( 'usermaildisabled', 'usermaildisabledtext' );
115        }
116
117        $request = $this->getRequest();
118        $this->formType = strtolower( $request->getText( 'formtype', $par ?? '' ) );
119
120        $config = $this->getTypeConfig();
121        $user = $this->getUser();
122
123        // Display error if user not logged in when config requires it
124        $requiresConfirmedEmail = $config['MustHaveEmail'] ?? false;
125        $requiresLogin = $config['MustBeLoggedIn'] ?? false;
126        if ( $requiresLogin ) {
127            $this->requireNamedUser( 'contactpage-mustbeloggedin' );
128        } elseif ( $requiresConfirmedEmail ) {
129            // MustHaveEmail must not be set without setting MustBeLoggedIn, as
130            // anon and temporary users do not have email addresses.
131            $this->getOutput()->showErrorPage( 'contactpage-config-error-title',
132                'contactpage-config-error' );
133            return;
134        }
135
136        // Display error if sender has no confirmed email when config requires it
137        if ( $requiresConfirmedEmail && !$user->isEmailConfirmed() ) {
138            $this->getOutput()->showErrorPage(
139                'contactpage-musthaveemail-error-title',
140                'contactpage-musthaveemail-error'
141            );
142            return;
143        }
144
145        // Display error if no recipient user specified in configuration
146        if ( !$config['RecipientUser'] ) {
147            $this->getOutput()->showErrorPage( 'contactpage-config-error-title',
148                'contactpage-config-error' );
149            return;
150        }
151
152        // Display error if recipient has email disabled
153        $recipient = User::newFromName( $config['RecipientUser'] );
154        if ( $recipient === null || !$recipient->canReceiveEmail() ) {
155            $this->getOutput()->showErrorPage( 'noemailtitle', 'noemailtext' );
156            return;
157        }
158
159        // Blocked users cannot use the contact form if they're disabled from sending email.
160        $block = $user->getBlock();
161        if ( $block && $block->appliesToRight( 'sendemail' ) ) {
162            $useCustomBlockMessage = $config['UseCustomBlockMessage'] ?? false;
163            if ( $useCustomBlockMessage ) {
164                $this->getOutput()->showErrorPage( $this->getFormSpecificMessageKey( 'contactpage-title' ),
165                    $this->getFormSpecificMessageKey( 'contactpage-blocked-message' ) );
166                return;
167            }
168
169            throw new UserBlockedError( $block );
170        }
171
172        $this->getOutput()->setPageTitleMsg(
173            $this->msg( $this->getFormSpecificMessageKey( 'contactpage-title' ) )
174        );
175
176        # Check for type in [[Special:Contact/type]]: change pagetext and prefill form fields
177        $formText = $this->msg(
178            $this->getFormSpecificMessageKey( 'contactpage-pagetext' )
179        )->parseAsBlock();
180        $formSpecificSubjectMessageKey = $this->msg( [
181            'contactpage-defsubject-' . $this->formType,
182            'contactpage-subject-' . $this->formType
183        ] );
184        if ( $this->formType != '' && !$formSpecificSubjectMessageKey->isDisabled() ) {
185            $subject = trim( $formSpecificSubjectMessageKey->inContentLanguage()->plain() );
186        } else {
187            $subject = $this->msg( 'contactpage-defsubject' )->inContentLanguage()->text();
188        }
189
190        $fromAddress = '';
191        $fromName = '';
192        $nameReadonly = false;
193        $emailReadonly = false;
194        $subjectReadonly = $config['SubjectReadonly'] ?? false;
195        if ( $user->isNamed() ) {
196            // Use real name if set
197            $realName = $user->getRealName();
198            if ( $realName ) {
199                $fromName = $realName;
200            } else {
201                $fromName = $user->getName();
202            }
203            $fromAddress = $user->getEmail();
204            $nameReadonly = $config['NameReadonly'] ?? false;
205            $emailReadonly = $config['EmailReadonly'] ?? false;
206        }
207
208        // Show error if the following are true as they are in combination invalid configuration:
209        // * The form doesn't require logging in
210        // * The form requires details
211        // * The email form is read only.
212        // This is because the email field will be empty for anon and temp users and must be filled
213        // for the form to be valid, but cannot be modified by the client.
214        if ( !$requiresLogin && $emailReadonly && $config['RequireDetails'] ) {
215            $this->getOutput()->showErrorPage( 'contactpage-config-error-title',
216                'contactpage-config-error' );
217            return;
218        }
219
220        $additional = $config['AdditionalFields'] ?? [];
221
222        $formItems = [
223            'FromName' => [
224                'label-message' => $this->getFormSpecificMessageKey( 'contactpage-fromname' ),
225                'type' => 'text',
226                'required' => $config['RequireDetails'],
227                'default' => $fromName,
228                'disabled' => $nameReadonly,
229            ],
230            'FromAddress' => [
231                'label-message' => $this->getFormSpecificMessageKey( 'contactpage-fromaddress' ),
232                'type' => 'email',
233                'required' => $config['RequireDetails'],
234                'default' => $fromAddress,
235                'disabled' => $emailReadonly,
236            ]
237        ];
238
239        if ( !$config['RequireDetails'] ) {
240            $formItems['FromInfo'] = [
241                'label' => '',
242                'type' => 'info',
243                'default' => Html::rawElement( 'small', [],
244                    $this->msg(
245                        $this->getFormSpecificMessageKey( 'contactpage-formfootnotes' )
246                    )->escaped()
247                ),
248                'raw' => true,
249            ];
250        }
251
252        $formItems += [
253            'Subject' => [
254                'label-message' => $this->getFormSpecificMessageKey( 'emailsubject' ),
255                'type' => 'text',
256                'default' => $subject,
257                'disabled' => $subjectReadonly,
258            ],
259        ] + $additional + [
260            'CCme' => [
261                'label-message' => $this->getFormSpecificMessageKey( 'emailccme' ),
262                'type' => 'check',
263                'default' => $this->userOptionsLookup->getBoolOption( $this->getUser(), 'ccmeonemails' ),
264            ],
265            'FormType' => [
266                'class' => 'HTMLHiddenField',
267                'label' => 'Type',
268                'default' => $this->formType,
269            ]
270        ];
271
272        if ( $config['IncludeIP'] && $user->isRegistered() ) {
273            $formItems['IncludeIP'] = [
274                'label-message' => $this->getFormSpecificMessageKey( 'contactpage-includeip' ),
275                'type' => 'check',
276            ];
277        }
278
279        if ( $this->useCaptcha() ) {
280            $formItems['Captcha'] = [
281                'label-message' => 'captcha-label',
282                'type' => 'info',
283                'default' => $this->getCaptcha(),
284                'raw' => true,
285            ];
286        }
287
288        $form = HTMLForm::factory( 'ooui',
289            $formItems, $this->getContext(), "contactpage-{$this->formType}"
290        );
291        $form->setWrapperLegendMsg( 'contactpage-legend' );
292        $form->setSubmitTextMsg( $this->getFormSpecificMessageKey( 'emailsend' ) );
293        if ( $this->formType != '' ) {
294            $form->setId( "contactpage-{$this->formType}" );
295
296            $msg = $this->msg( "contactpage-legend-{$this->formType}" );
297            if ( !$msg->isDisabled() ) {
298                $form->setWrapperLegendMsg( $msg );
299            }
300
301            $msg = $this->msg( "contactpage-emailsend-{$this->formType}" );
302            if ( !$msg->isDisabled() ) {
303                $form->setSubmitTextMsg( $msg );
304            }
305        }
306        $form->setSubmitCallback( [ $this, 'processInput' ] );
307        $form->loadData();
308
309        // Stolen from Special:EmailUser
310        if ( !$this->getContactPageHookRunner()->onEmailUserForm( $form ) ) {
311            return;
312        }
313
314        $result = $form->show();
315
316        if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) {
317            $output = $this->getOutput();
318            $output->setPageTitleMsg( $this->msg( $this->getFormSpecificMessageKey( 'emailsent' ) ) );
319            $output->addWikiMsg(
320                $this->getFormSpecificMessageKey( 'emailsenttext' ),
321                $recipient
322            );
323
324            $output->returnToMain( false );
325        } else {
326            if ( $config['RLStyleModules'] ) {
327                $this->getOutput()->addModuleStyles( $config['RLStyleModules'] );
328            }
329            if ( $config['RLModules'] ) {
330                $this->getOutput()->addModules( $config['RLModules'] );
331            }
332            $this->getOutput()->prependHTML( trim( $formText ) );
333        }
334    }
335
336    /**
337     * @param array $formData
338     * @return bool|string|array|Status
339     *     - Bool true or a good Status object indicates success,
340     *     - Bool false indicates no submission was attempted,
341     *     - Anything else indicates failure. The value may be a fatal Status
342     *       object, an HTML string, or an array of arrays (message keys and
343     *       params) or strings (message keys)
344     */
345    public function processInput( $formData ) {
346        $config = $this->getTypeConfig();
347
348        $request = $this->getRequest();
349        $user = $this->getUser();
350
351        if ( $this->useCaptcha() &&
352            !$this->getConfig()->get( 'Captcha' )->passCaptchaFromRequest( $request, $user )
353        ) {
354            return [ 'contactpage-captcha-error' ];
355        }
356
357        $senderIP = $request->getIP();
358
359        // Setup user that is going to receive the contact page response
360        $contactRecipientUser = User::newFromName( $config['RecipientUser'] );
361        $contactRecipientAddress = MailAddress::newFromUser( $contactRecipientUser );
362
363        // Used when user hasn't set an email, when $wgUserEmailUseReplyTo is true,
364        // or when sending CC email to user
365        $siteAddress = new MailAddress(
366            $config['SenderEmail'] ?: $this->getConfig()->get( 'PasswordSender' ),
367            $config['SenderName']
368        );
369
370        // Initialize the sender to the site address
371        $senderAddress = $siteAddress;
372
373        $fromAddress = $formData['FromAddress'];
374        $fromName = $formData['FromName'];
375
376        $fromUserAddress = null;
377        $replyTo = null;
378
379        if ( $fromAddress ) {
380            // T232199 - If the email address is invalid, bail out.
381            // Don't allow it to fallback to basically @server.host.name
382            if ( !Sanitizer::validateEmail( $fromAddress ) ) {
383                return [ 'invalidemailaddress' ];
384            }
385
386            // Use user submitted details
387            $fromUserAddress = new MailAddress( $fromAddress, $fromName );
388
389            if ( $this->getConfig()->get( 'UserEmailUseReplyTo' ) ) {
390                // Define reply-to address
391                $replyTo = $fromUserAddress;
392            } else {
393                // Not using ReplyTo, so set the sender to $fromUserAddress
394                $senderAddress = $fromUserAddress;
395            }
396        }
397
398        $includeIP = isset( $config['IncludeIP'] ) && $config['IncludeIP']
399            && ( $user->isAnon() || $formData['IncludeIP'] );
400        $subject = $formData['Subject'];
401
402        if ( $fromName !== '' ) {
403            if ( $includeIP ) {
404                $subject = $this->msg(
405                    'contactpage-subject-and-sender-withip',
406                    $subject,
407                    $fromName,
408                    $senderIP
409                )->inContentLanguage()->text();
410            } else {
411                $subject = $this->msg(
412                    'contactpage-subject-and-sender',
413                    $subject,
414                    $fromName
415                )->inContentLanguage()->text();
416            }
417        } elseif ( $fromAddress !== '' ) {
418            if ( $includeIP ) {
419                $subject = $this->msg(
420                    'contactpage-subject-and-sender-withip',
421                    $subject,
422                    $fromAddress,
423                    $senderIP
424                )->inContentLanguage()->text();
425            } else {
426                $subject = $this->msg(
427                    'contactpage-subject-and-sender',
428                    $subject,
429                    $fromAddress
430                )->inContentLanguage()->text();
431            }
432        } elseif ( $includeIP ) {
433            $subject = $this->msg(
434                'contactpage-subject-and-sender',
435                $subject,
436                $senderIP
437            )->inContentLanguage()->text();
438        }
439
440        $text = '';
441        foreach ( $config['AdditionalFields'] as $name => $field ) {
442            $class = HTMLForm::getClassFromDescriptor( $name, $field );
443
444            $value = '';
445            // TODO: Support selectandother/HTMLSelectAndOtherField
446            // options, options-messages and options-message
447            if ( isset( $field['options-messages'] ) ) {
448                // Multiple values!
449                if ( is_string( $formData[$name] ) ) {
450                    $optionValues = array_flip( $field['options-messages'] );
451                    if ( isset( $optionValues[$formData[$name]] ) ) {
452                        $value = $this->msg( $optionValues[$formData[$name]] )->inContentLanguage()->text();
453                    } else {
454                        $value = $formData[$name];
455                    }
456                } elseif ( count( $formData[$name] ) ) {
457                    $formValues = array_flip( $formData[$name] );
458                    $value .= "\n";
459                    foreach ( $field['options-messages'] as $msg => $optionValue ) {
460                        $msg = $this->msg( $msg )->inContentLanguage()->text();
461                        $optionValue = $this->getYesOrNoMsg( isset( $formValues[$optionValue] ) );
462                        $value .= "\t$msg$optionValue\n";
463                    }
464                }
465            } elseif ( isset( $field['options'] ) ) {
466                if ( is_string( $formData[$name] ) ) {
467                    $value = $formData[$name];
468                } elseif ( count( $formData[$name] ) ) {
469                    $formValues = array_flip( $formData[$name] );
470                    $value .= "\n";
471                    foreach ( $field['options'] as $msg => $optionValue ) {
472                        $optionValue = $this->getYesOrNoMsg( isset( $formValues[$optionValue] ) );
473                        $value .= "\t$msg$optionValue\n";
474                    }
475                }
476            } elseif ( $class === 'HTMLCheckField' ) {
477                $value = $this->getYesOrNoMsg( $formData[$name] xor
478                    ( isset( $field['invert'] ) && $field['invert'] ) );
479            } elseif ( isset( $formData[$name] ) ) {
480                // HTMLTextField, HTMLTextAreaField
481                // HTMLFloatField, HTMLIntField
482
483                // Just dump the value if its wordy
484                $value = $formData[$name];
485            } else {
486                continue;
487            }
488
489            if ( isset( $field['contactpage-email-label'] ) ) {
490                $name = $field['contactpage-email-label'];
491            } elseif ( isset( $field['label-message'] ) ) {
492                $name = $this->msg( $field['label-message'] )->inContentLanguage()->text();
493            } else {
494                $name = $field['label'];
495            }
496
497            $text .= "{$name}$value\n";
498        }
499
500        $hookRunner = $this->getContactPageHookRunner();
501        if ( !$hookRunner->onContactForm( $contactRecipientAddress, $replyTo, $subject,
502            $text, $this->formType, $formData )
503        ) {
504            // TODO: Need to do some proper error handling here
505            return false;
506        }
507
508        wfDebug( __METHOD__ . ': sending mail from ' . $senderAddress->toString() .
509            ' to ' . $contactRecipientAddress->toString() .
510            ' replyto ' . ( $replyTo == null ? '-/-' : $replyTo->toString() ) . "\n"
511        );
512        // @phan-suppress-next-line SecurityCheck-XSS UserMailer::send defaults to text/plain if passed a string
513        $mailResult = UserMailer::send(
514            $contactRecipientAddress,
515            $senderAddress,
516            $subject,
517            $text,
518            [ 'replyTo' => $replyTo ]
519        );
520
521        $language = $this->getLanguage();
522        if ( !$mailResult->isOK() ) {
523            wfDebug( __METHOD__ . ': got error from UserMailer: ' .
524                $mailResult->getMessage( false, false, 'en' )->text() . "\n" );
525            return [ $mailResult->getMessage( 'contactpage-usermailererror', false, $language ) ];
526        }
527
528        // if the user requested a copy of this mail, do this now,
529        // unless they are emailing themselves, in which case one copy of the message is sufficient.
530        if ( $formData['CCme'] && $fromUserAddress ) {
531            $cc_subject = $this->msg( 'emailccsubject', $contactRecipientUser->getName(), $subject )->text();
532            if ( $hookRunner->onContactForm(
533                $fromUserAddress, $senderAddress, $cc_subject, $text, $this->formType, $formData )
534            ) {
535                wfDebug( __METHOD__ . ': sending cc mail from ' . $senderAddress->toString() .
536                    ' to ' . $fromUserAddress->toString() . "\n"
537                );
538                // @phan-suppress-next-line SecurityCheck-XSS UserMailer::send defaults to text/plain if passed a string
539                $ccResult = UserMailer::send(
540                    $fromUserAddress,
541                    $senderAddress,
542                    $cc_subject,
543                    $text,
544                );
545                if ( !$ccResult->isOK() ) {
546                    // At this stage, the user's CC mail has failed, but their
547                    // original mail has succeeded. It's unlikely, but still, what to do?
548                    // We can either show them an error, or we can say everything was fine,
549                    // or we can say we sort of failed AND sort of succeeded. Of these options,
550                    // simply saying there was an error is probably best.
551                    return [ $ccResult->getMessage( 'contactpage-usermailererror', false, $language ) ];
552                }
553            }
554        }
555
556        $hookRunner->onContactFromComplete( $contactRecipientAddress, $replyTo, $subject, $text );
557
558        return true;
559    }
560
561    /**
562     * @param bool $value
563     * @return string
564     */
565    private function getYesOrNoMsg( $value ) {
566        return $this->msg( $value ? 'htmlform-yes' : 'htmlform-no' )->inContentLanguage()->text();
567    }
568
569    /**
570     * @return bool True if CAPTCHA should be used, false otherwise
571     */
572    private function useCaptcha() {
573        $extRegistry = ExtensionRegistry::getInstance();
574        if ( !$extRegistry->isLoaded( 'ConfirmEdit' ) ) {
575             return false;
576        }
577        $config = $this->getConfig();
578        $captchaTriggers = $config->get( 'CaptchaTriggers' );
579
580        return $config->get( 'CaptchaClass' )
581            && isset( $captchaTriggers['contactpage'] )
582            && $captchaTriggers['contactpage']
583            && !$this->getUser()->isAllowed( 'skipcaptcha' );
584    }
585
586    /**
587     * @return string CAPTCHA form HTML
588     */
589    private function getCaptcha() {
590        // NOTE: make sure we have a session. May be required for CAPTCHAs to work.
591        SessionManager::getGlobalSession()->persist();
592
593        $captcha = ConfirmEditHooks::getInstance();
594        $captcha->setTrigger( 'contactpage' );
595        $captcha->setAction( 'contact' );
596
597        $formInformation = $captcha->getFormInformation();
598        $formMetainfo = $formInformation;
599        unset( $formMetainfo['html'] );
600        $captcha->addFormInformationToOutput( $this->getOutput(), $formMetainfo );
601
602        return '<div class="captcha">' .
603            $formInformation['html'] .
604            "</div>\n";
605    }
606
607    /**
608     * @return HookRunner
609     */
610    private function getContactPageHookRunner() {
611        if ( !$this->contactPageHookRunner ) {
612            $this->contactPageHookRunner = new HookRunner( $this->getHookContainer() );
613        }
614        return $this->contactPageHookRunner;
615    }
616}