Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 359
0.00% covered (danger)
0.00%
0 / 31
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialMergeAccount
0.00% covered (danger)
0.00%
0 / 359
0.00% covered (danger)
0.00%
0 / 31
6006
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
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 / 54
0.00% covered (danger)
0.00%
0 / 1
210
 showFormForExistingUsers
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 initSession
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 getWorkingPasswords
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 addWorkingPassword
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 clearWorkingPasswords
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 xorString
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 doDryRunMerge
0.00% covered (danger)
0.00%
0 / 42
0.00% covered (danger)
0.00%
0 / 1
56
 doInitialMerge
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
20
 doCleanupMerge
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 doAttachMerge
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 showWelcomeForm
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 showCleanupForm
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 showAttachForm
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 showStatus
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
20
 listAttached
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 listUnattached
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 listWikis
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 formatList
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 listWikiItem
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 foreignUserLink
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
6
 actionForm
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
2
 passwordForm
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
2
 step1PasswordForm
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 step2PasswordForm
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 step3ActionForm
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
6
 attachActionForm
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 dryRunError
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\CentralAuth\Special;
4
5use ErrorPageError;
6use Exception;
7use InvalidArgumentException;
8use MediaWiki\Extension\CentralAuth\CentralAuthDatabaseManager;
9use MediaWiki\Extension\CentralAuth\User\CentralAuthUser;
10use MediaWiki\Html\Html;
11use MediaWiki\SpecialPage\SpecialPage;
12use MediaWiki\Title\NamespaceInfo;
13use MediaWiki\User\UserFactory;
14use MediaWiki\WikiMap\WikiMap;
15use MWCryptRand;
16use RuntimeException;
17use Wikimedia\AtEase\AtEase;
18use Xml;
19
20class SpecialMergeAccount extends SpecialPage {
21    /** @var string */
22    protected $mUserName;
23    /** @var bool */
24    protected $mAttemptMerge;
25    /** @var string */
26    protected $mMergeAction;
27    /** @var string */
28    protected $mPassword;
29    /** @var string[] */
30    protected $mWikiIDs;
31    /** @var string */
32    protected $mSessionToken;
33    /** @var string */
34    protected $mSessionKey;
35
36    /** @var NamespaceInfo */
37    private $namespaceInfo;
38    /** @var UserFactory */
39    private $userFactory;
40
41    /** @var CentralAuthDatabaseManager */
42    private $databaseManager;
43
44    /**
45     * @param NamespaceInfo $namespaceInfo
46     * @param UserFactory $userFactory
47     * @param CentralAuthDatabaseManager $databaseManager
48     */
49    public function __construct(
50        NamespaceInfo $namespaceInfo,
51        UserFactory $userFactory,
52        CentralAuthDatabaseManager $databaseManager
53    ) {
54        parent::__construct( 'MergeAccount', 'centralauth-merge' );
55        $this->namespaceInfo = $namespaceInfo;
56        $this->userFactory = $userFactory;
57        $this->databaseManager = $databaseManager;
58    }
59
60    public function doesWrites() {
61        return true;
62    }
63
64    /** @inheritDoc */
65    public function execute( $subpage ) {
66        $this->setHeaders();
67        $this->addHelpLink( 'Extension:CentralAuth' );
68
69        if ( $subpage !== null && preg_match( "/^[0-9a-zA-Z]{32}$/", $subpage ) ) {
70            $user = $this->userFactory->newFromConfirmationCode( $subpage );
71
72            if ( is_object( $user ) ) {
73                $user->confirmEmail();
74                $user->saveSettings();
75                $this->getOutput()->addWikiMsg( 'confirmemail_success' );
76            } else {
77                $this->getOutput()->addWikiMsg( 'confirmemail_invalid' );
78                // return; // Let's be greedy and still show them MergeAccount
79            }
80        }
81
82        if ( !$this->userCanExecute( $this->getUser() ) ) {
83            $this->getOutput()->addWikiMsg( 'centralauth-merge-denied' );
84            $this->getOutput()->addWikiMsg( 'centralauth-readmore-text' );
85            return;
86        }
87
88        if ( !$this->getUser()->isRegistered() ) {
89            $loginpage = SpecialPage::getTitleFor( 'Userlogin' );
90            $loginurl = $loginpage->getFullUrl(
91                [ 'returnto' => $this->getPageTitle()->getPrefixedText() ]
92            );
93            $this->getOutput()->addWikiMsg( 'centralauth-merge-notlogged', $loginurl );
94            $this->getOutput()->addWikiMsg( 'centralauth-readmore-text' );
95
96            return;
97        }
98
99        $this->databaseManager->assertNotReadOnly();
100        $request = $this->getRequest();
101
102        $this->mUserName = $this->getUser()->getName();
103
104        $this->mAttemptMerge = $request->wasPosted();
105
106        $this->mMergeAction = $request->getVal( 'wpMergeAction' );
107        $this->mPassword = $request->getVal( 'wpPassword' );
108        $this->mWikiIDs = $request->getArray( 'wpWikis' );
109        $this->mSessionToken = $request->getVal( 'wpMergeSessionToken' );
110        $this->mSessionKey = pack( "H*", $request->getVal( 'wpMergeSessionKey' ) );
111
112        // Possible demo states
113
114        // success, all accounts merged
115        // successful login, some accounts merged, others left
116        // successful login, others left
117        // not account owner, others left
118
119        // is owner / is not owner
120        // did / did not merge some accounts
121        // do / don't have more accounts to merge
122
123        if ( $this->mAttemptMerge ) {
124            // First check the edit token
125            if ( !$this->getUser()->matchEditToken( $request->getVal( 'wpEditToken' ) ) ) {
126                throw new ErrorPageError( 'sessionfailure-title', 'sessionfailure' );
127            }
128            switch ( $this->mMergeAction ) {
129                case "dryrun":
130                    $this->doDryRunMerge();
131                    break;
132                case "initial":
133                    $this->doInitialMerge();
134                    break;
135                case "cleanup":
136                    $this->doCleanupMerge();
137                    break;
138                case "attach":
139                    $this->doAttachMerge();
140                    break;
141                default:
142                    throw new InvalidArgumentException(
143                        'Invalid merge action ' . $this->mMergeAction . ' given'
144                    );
145            }
146            return;
147        }
148
149        $globalUser = CentralAuthUser::getInstanceByName( $this->mUserName );
150        if ( $globalUser->exists() ) {
151            $this->showFormForExistingUsers( $globalUser );
152        } else {
153            $this->showWelcomeForm();
154        }
155    }
156
157    /**
158     * Pick which form to show for a user that already exists
159     *
160     * @param CentralAuthUser $globalUser
161     */
162    private function showFormForExistingUsers( CentralAuthUser $globalUser ) {
163        if ( $globalUser->isAttached() ) {
164            $this->showCleanupForm();
165        } else {
166            $this->showAttachForm();
167        }
168    }
169
170    /**
171     * To pass potentially multiple passwords from one form submission
172     * to another while previewing the merge behavior, we can store them
173     * in the server-side session information.
174     *
175     * We'd rather not have plaintext passwords floating about on disk
176     * or memcached, so the session store is obfuscated with simple XOR
177     * encryption. The key is passed in the form instead of the session
178     * data, so they won't be found floating in the same place.
179     */
180    private function initSession() {
181        $this->mSessionToken = MWCryptRand::generateHex( 32 );
182        $this->mSessionKey = random_bytes( 128 );
183    }
184
185    /**
186     * @return string[]
187     */
188    private function getWorkingPasswords() {
189        AtEase::suppressWarnings();
190        $data = $this->getRequest()->getSessionData( 'wsCentralAuthMigration' );
191        $passwords = unserialize(
192            gzinflate(
193                $this->xorString(
194                    $data[$this->mSessionToken],
195                    $this->mSessionKey
196                )
197            )
198        );
199        AtEase::restoreWarnings();
200        if ( is_array( $passwords ) ) {
201            return $passwords;
202        }
203        return [];
204    }
205
206    /**
207     * @param string $password
208     */
209    private function addWorkingPassword( $password ) {
210        $passwords = $this->getWorkingPasswords();
211        if ( !in_array( $password, $passwords ) ) {
212            $passwords[] = $password;
213        }
214
215        // Lightly obfuscate the passwords while we're storing them,
216        // just to make us feel better about them floating around.
217        $request = $this->getRequest();
218        $data = $request->getSessionData( 'wsCentralAuthMigration' );
219        $data[$this->mSessionToken] =
220            $this->xorString(
221                gzdeflate(
222                    serialize(
223                        $passwords ) ),
224                $this->mSessionKey );
225        $request->setSessionData( 'wsCentralAuthMigration', $data );
226    }
227
228    private function clearWorkingPasswords() {
229        $request = $this->getRequest();
230        $data = $request->getSessionData( 'wsCentralAuthMigration' );
231        unset( $data[$this->mSessionToken] );
232        $request->setSessionData( 'wsCentralAuthMigration', $data );
233    }
234
235    /**
236     * @param string $text
237     * @param string $key
238     * @return string
239     */
240    private function xorString( $text, $key ) {
241        if ( $key !== '' ) {
242            $textLen = strlen( $text );
243            $keyLen = strlen( $key );
244            for ( $i = 0; $i < $textLen; $i++ ) {
245                $text[$i] = chr( 0xff & ( ord( $text[$i] ) ^ ord( $key[$i % $keyLen] ) ) );
246            }
247        }
248        return $text;
249    }
250
251    private function doDryRunMerge() {
252        $globalUser = CentralAuthUser::getPrimaryInstance( $this->getUser() );
253
254        if ( $globalUser->exists() ) {
255            // Already exists - race condition
256            $this->showFormForExistingUsers( $globalUser );
257            return;
258        }
259
260        if ( $this->getConfig()->get( 'CentralAuthDryRun' ) ) {
261            $this->getOutput()->addHTML(
262                Html::successBox(
263                    $this->msg( 'centralauth-notice-dryrun' )->parseAsBlock()
264                )
265            );
266        }
267
268        $password = $this->getRequest()->getVal( 'wpPassword' );
269        $this->addWorkingPassword( $password );
270        $passwords = $this->getWorkingPasswords();
271
272        $home = false;
273        $attached = [];
274        $unattached = [];
275        $methods = [];
276        $status = $globalUser->migrationDryRun(
277            $passwords, $home, $attached, $unattached, $methods
278        );
279
280        if ( $status->isGood() ) {
281            // This is the global account or matched it
282            if ( count( $unattached ) == 0 ) {
283                // Everything matched -- very convenient!
284                $this->getOutput()->addWikiMsg( 'centralauth-merge-dryrun-complete' );
285            } else {
286                $this->getOutput()->addWikiMsg( 'centralauth-merge-dryrun-incomplete' );
287            }
288
289            if ( count( $unattached ) > 0 ) {
290                $this->getOutput()->addHTML( $this->step2PasswordForm( $unattached ) );
291                $this->getOutput()->addWikiMsg( 'centralauth-merge-dryrun-or' );
292            }
293
294            $subAttached = array_diff( $attached, [ $home ] );
295            $this->getOutput()->addHTML( $this->step3ActionForm( $home, $subAttached, $methods ) );
296        } else {
297            // Show error message from status
298            $out = $this->getOutput();
299            $out->addHTML(
300                Html::errorBox(
301                    $out->parseAsInterface( $status->getWikiText() )
302                )
303            );
304
305            // Show wiki list if required
306            if ( $status->hasMessage( 'centralauth-merge-home-password' ) ) {
307                $out = Html::rawElement( 'h2', [],
308                    $this->msg( 'centralauth-list-home-title' )->escaped() );
309                $out .= $this->msg( 'centralauth-list-home-dryrun' )->parseAsBlock();
310                $out .= $this->listAttached( [ $home ], [ $home => 'primary' ] );
311                $this->getOutput()->addHTML( $out );
312            }
313
314            // Show password box
315            $this->getOutput()->addHTML( $this->step1PasswordForm() );
316        }
317    }
318
319    private function doInitialMerge() {
320        $globalUser = CentralAuthUser::getPrimaryInstance( $this->getUser() );
321
322        if ( $this->getConfig()->get( 'CentralAuthDryRun' ) ) {
323            $this->dryRunError();
324            return;
325        }
326
327        if ( $globalUser->exists() ) {
328            // Already exists - race condition
329            $this->showFormForExistingUsers( $globalUser );
330            return;
331        }
332
333        $passwords = $this->getWorkingPasswords();
334        if ( !$passwords ) {
335            throw new RuntimeException( "Submission error -- invalid input" );
336        }
337
338        $globalUser->storeAndMigrate(
339            $passwords,
340            true,
341            false,
342            true
343        );
344        $this->clearWorkingPasswords();
345
346        $this->showCleanupForm();
347    }
348
349    private function doCleanupMerge() {
350        $globalUser = CentralAuthUser::getPrimaryInstance( $this->getUser() );
351
352        if ( !$globalUser->exists() ) {
353            throw new RuntimeException( "User doesn't exist -- race condition?" );
354        }
355
356        if ( !$globalUser->isAttached() ) {
357            throw new RuntimeException( "Can't cleanup merge if not already attached." );
358        }
359
360        if ( $this->getConfig()->get( 'CentralAuthDryRun' ) ) {
361            $this->dryRunError();
362            return;
363        }
364        $password = $this->getRequest()->getText( 'wpPassword' );
365
366        $attached = [];
367        $unattached = [];
368        $ok = $globalUser->attemptPasswordMigration( $password, $attached, $unattached );
369        $this->clearWorkingPasswords();
370
371        if ( !$ok ) {
372            if ( !$attached ) {
373                $this->getOutput()->addWikiMsg( 'centralauth-finish-noconfirms' );
374            } else {
375                $this->getOutput()->addWikiMsg( 'centralauth-finish-incomplete' );
376            }
377        }
378        $this->showCleanupForm();
379    }
380
381    private function doAttachMerge() {
382        $globalUser = CentralAuthUser::getPrimaryInstance( $this->getUser() );
383
384        if ( !$globalUser->exists() ) {
385            throw new RuntimeException( "User doesn't exist -- race condition?" );
386        }
387
388        if ( $globalUser->isAttached() ) {
389            // Already attached - race condition
390            $this->showCleanupForm();
391            return;
392        }
393
394        if ( $this->getConfig()->get( 'CentralAuthDryRun' ) ) {
395            $this->dryRunError();
396            return;
397        }
398        $password = $this->getRequest()->getText( 'wpPassword' );
399        if ( $globalUser->authenticate( $password ) == [ CentralAuthUser::AUTHENTICATE_OK ] ) {
400            $globalUser->attach( WikiMap::getCurrentWikiId(), 'password' );
401            $this->getOutput()->addWikiMsg( 'centralauth-attach-success' );
402            $this->showCleanupForm();
403        } else {
404            $this->getOutput()->addHTML(
405                Html::errorBox(
406                    $this->msg( 'wrongpassword' )->escaped()
407                ) . $this->attachActionForm()
408            );
409        }
410    }
411
412    private function showWelcomeForm() {
413        if ( $this->getConfig()->get( 'CentralAuthDryRun' ) ) {
414            $this->getOutput()->addHTML(
415                Html::successBox(
416                    $this->msg( 'centralauth-notice-dryrun' )->parseAsBlock()
417                )
418            );
419        }
420
421        $this->getOutput()->addWikiMsg( 'centralauth-merge-welcome' );
422        $this->getOutput()->addWikiMsg( 'centralauth-readmore-text' );
423
424        $this->initSession();
425        $this->getOutput()->addHTML(
426            $this->passwordForm(
427                'dryrun',
428                $this->msg( 'centralauth-merge-step1-title' )->text(),
429                $this->msg( 'centralauth-merge-step1-detail' )->escaped(),
430                $this->msg( 'centralauth-merge-step1-submit' )->text() )
431            );
432    }
433
434    private function showCleanupForm() {
435        $globalUser = CentralAuthUser::getInstance( $this->getUser() );
436
437        $merged = $globalUser->listAttached();
438        $remainder = $globalUser->listUnattached();
439        $this->showStatus( $merged, $remainder );
440    }
441
442    private function showAttachForm() {
443        $globalUser = CentralAuthUser::getInstance( $this->getUser() );
444        $merged = $globalUser->listAttached();
445        $this->getOutput()->addWikiMsg( 'centralauth-attach-list-attached', $this->mUserName );
446        $this->getOutput()->addHTML( $this->listAttached( $merged ) );
447        $this->getOutput()->addHTML( $this->attachActionForm() );
448    }
449
450    /**
451     * @param string[] $merged
452     * @param string[] $remainder
453     */
454    private function showStatus( $merged, $remainder ) {
455        $remainderCount = count( $remainder );
456        if ( $remainderCount > 0 ) {
457            $this->getOutput()->setPageTitleMsg( $this->msg( 'centralauth-incomplete' ) );
458            $this->getOutput()->addWikiMsg( 'centralauth-incomplete-text' );
459        } else {
460            $this->getOutput()->setPageTitleMsg( $this->msg( 'centralauth-complete' ) );
461            $this->getOutput()->addWikiMsg( 'centralauth-complete-text' );
462        }
463        $this->getOutput()->addWikiMsg( 'centralauth-readmore-text' );
464
465        if ( $merged ) {
466            $this->getOutput()->addHTML( Xml::element( 'hr' ) );
467            $this->getOutput()->addWikiMsg(
468                'centralauth-list-attached',
469                $this->mUserName,
470                count( $merged )
471            );
472            $this->getOutput()->addHTML( $this->listAttached( $merged ) );
473        }
474
475        if ( $remainder ) {
476            $this->getOutput()->addHTML( Xml::element( 'hr' ) );
477            $this->getOutput()->addWikiMsg(
478                'centralauth-list-unattached',
479                $this->mUserName,
480                $remainderCount
481            );
482            $this->getOutput()->addHTML( $this->listUnattached( $remainder ) );
483
484            // Try the password form!
485            $this->getOutput()->addHTML( $this->passwordForm(
486                'cleanup',
487                $this->msg( 'centralauth-finish-title' )->text(),
488                $this->msg( 'centralauth-finish-text' )->parseAsBlock(),
489                $this->msg( 'centralauth-finish-login' )->text() ) );
490        }
491    }
492
493    /**
494     * @param string[] $wikiList
495     * @param string[] $methods
496     * @return string
497     */
498    private function listAttached( $wikiList, $methods = [] ) {
499        return $this->listWikis( $wikiList, $methods );
500    }
501
502    /**
503     * @param string[] $wikiList
504     * @return string
505     */
506    private function listUnattached( $wikiList ) {
507        return $this->listWikis( $wikiList );
508    }
509
510    /**
511     * @param string[] $list
512     * @param string[] $methods
513     * @return string
514     */
515    private function listWikis( $list, $methods = [] ) {
516        asort( $list );
517        return $this->formatList( $list, $methods, [ $this, 'listWikiItem' ] );
518    }
519
520    /**
521     * @param string[] $items
522     * @param string[] $methods
523     * @param callable $callback
524     * @return string
525     */
526    private function formatList( $items, $methods, $callback ) {
527        if ( !$items ) {
528            return '';
529        }
530
531        $itemMethods = [];
532        foreach ( $items as $item ) {
533            $itemMethods[] = $methods[$item] ?? '';
534        }
535
536        $html = Xml::openElement( 'ul' ) . "\n";
537        $list = array_map( $callback, $items, $itemMethods );
538        foreach ( $list as $item ) {
539            $html .= Html::rawElement( 'li', [], $item ) . "\n";
540        }
541        $html .= Xml::closeElement( 'ul' ) . "\n";
542
543        return $html;
544    }
545
546    /**
547     * @param string $wikiID
548     * @param string $method
549     * @return string
550     */
551    private function listWikiItem( $wikiID, $method ) {
552        $return = $this->foreignUserLink( $wikiID );
553        if ( $method ) {
554            // Give grep a chance to find the usages:
555            // centralauth-merge-method-primary, centralauth-merge-method-empty,
556            // centralauth-merge-method-mail, centralauth-merge-method-password,
557            // centralauth-merge-method-admin, centralauth-merge-method-new,
558            // centralauth-merge-method-login,
559            $return .= $this->msg( 'word-separator' )->escaped();
560            $return .= $this->msg( 'parentheses',
561                $this->msg( 'centralauth-merge-method-' . $method )->text()
562            )->escaped();
563        }
564        return $return;
565    }
566
567    /**
568     * @param string $wikiID
569     * @return string
570     * @throws Exception
571     */
572    private function foreignUserLink( $wikiID ) {
573        $wiki = WikiMap::getWiki( $wikiID );
574        if ( !$wiki ) {
575            throw new InvalidArgumentException( "Invalid wiki: $wikiID" );
576        }
577
578        $wikiname = $wiki->getDisplayName();
579        return SpecialCentralAuth::foreignLink(
580            $wiki,
581            $this->namespaceInfo->getCanonicalName( NS_USER ) . ':' . $this->mUserName,
582            $wikiname,
583            $this->msg( 'centralauth-foreign-link', $this->mUserName, $wikiname )->text()
584        );
585    }
586
587    /**
588     * @param string $action Value for wpMergeAction hidden form field
589     * @param string $title Header for form (Plain text. Will be escaped by this method)
590     * @param string $text Raw html contents of form
591     * @return string HTML of form
592     */
593    private function actionForm( $action, $title, $text ) {
594        return Xml::openElement( 'div', [ 'id' => "userloginForm" ] ) .
595            Xml::openElement( 'form',
596                [
597                    'method' => 'post',
598                    'action' => $this->getPageTitle()->getLocalUrl( 'action=submit' ) ] ) .
599            Xml::element( 'h2', [], $title ) .
600            Html::hidden( 'wpEditToken', $this->getUser()->getEditToken() ) .
601            Html::hidden( 'wpMergeAction', $action ) .
602            Html::hidden( 'wpMergeSessionToken', $this->mSessionToken ) .
603            Html::hidden( 'wpMergeSessionKey', bin2hex( $this->mSessionKey ) ) .
604
605            $text .
606
607            Xml::closeElement( 'form' ) .
608            Xml::element( 'br', [ 'clear' => 'all' ] ) .
609            Xml::closeElement( 'div' );
610    }
611
612    /**
613     * @param string $action wpMergeAction form value
614     * @param string $title Header of form (Not html escaped)
615     * @param string $text Raw html contents of form
616     * @param string $submit Text of submit button (Not html escaped)
617     * @return string HTML of form.
618     */
619    private function passwordForm( $action, $title, $text, $submit ) {
620        $table = Html::rawElement( 'table', [],
621            Html::rawElement( 'tr', [],
622                Html::rawElement( 'td', [],
623                    Xml::label(
624                        $this->msg( 'centralauth-finish-password' )->text(),
625                        'wpPassword1'
626                    )
627                ) .
628                Html::rawElement( 'td', [],
629                    Xml::input(
630                        'wpPassword', 20, '',
631                        [ 'type' => 'password', 'id' => 'wpPassword1' ] )
632                )
633            ) .
634            Html::rawElement( 'tr', [],
635                Html::rawElement( 'td' ) .
636                Html::rawElement( 'td', [],
637                    Xml::submitButton( $submit, [ 'name' => 'wpLogin' ] )
638                )
639            )
640        );
641        return $this->actionForm( $action, $title, $text . $table );
642    }
643
644    /**
645     * @return string
646     */
647    private function step1PasswordForm() {
648        return $this->passwordForm(
649            'dryrun',
650            $this->msg( 'centralauth-merge-step1-title' )->text(),
651            $this->msg( 'centralauth-merge-step1-detail' )->escaped(),
652            $this->msg( 'centralauth-merge-step1-submit' )->text() );
653    }
654
655    /**
656     * @param string[] $unattached
657     * @return string
658     */
659    private function step2PasswordForm( $unattached ) {
660        return $this->passwordForm(
661            'dryrun',
662            $this->msg( 'centralauth-merge-step2-title' )->text(),
663            $this->msg( 'centralauth-merge-step2-detail',
664                $this->getUser()->getName() )->parseAsBlock() .
665                $this->listUnattached( $unattached ),
666            $this->msg( 'centralauth-merge-step2-submit' )->text() );
667    }
668
669    /**
670     * @param string $home
671     * @param string[] $attached
672     * @param string[] $methods
673     * @return string
674     */
675    private function step3ActionForm( $home, $attached, $methods ) {
676        $html = $this->msg( 'centralauth-merge-step3-detail',
677            $this->getUser()->getName() )->parseAsBlock() .
678            Html::rawElement( 'h3', [],
679                $this->msg( 'centralauth-list-home-title' )->escaped()
680            ) . $this->msg( 'centralauth-list-home-dryrun' )->parseAsBlock() .
681            $this->listAttached( [ $home ], $methods );
682
683        if ( count( $attached ) ) {
684            $html .= Html::rawElement( 'h3', [],
685                $this->msg( 'centralauth-list-attached-title' )->escaped()
686            ) . $this->msg( 'centralauth-list-attached-dryrun',
687                $this->getUser()->getName(),
688                count( $attached ) )->parseAsBlock();
689        }
690
691        $html .= $this->listAttached( $attached, $methods ) .
692            Html::rawElement( 'p', [],
693                Xml::submitButton( $this->msg( 'centralauth-merge-step3-submit' )->text(),
694                    [ 'name' => 'wpLogin' ] )
695            );
696
697        return $this->actionForm(
698            'initial',
699            $this->msg( 'centralauth-merge-step3-title' )->text(),
700            $html
701        );
702    }
703
704    /**
705     * @return string
706     */
707    private function attachActionForm() {
708        return $this->passwordForm(
709            'attach',
710            $this->msg( 'centralauth-attach-title' )->text(),
711            $this->msg( 'centralauth-attach-text' )->escaped(),
712            $this->msg( 'centralauth-attach-submit' )->text() );
713    }
714
715    private function dryRunError() {
716        $this->getOutput()->addWikiMsg( 'centralauth-disabled-dryrun' );
717    }
718
719    /** @inheritDoc */
720    protected function getGroupName() {
721        return 'login';
722    }
723}