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