1<?php
2/**
3 * Implements Special:BotPasswords
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 * @ingroup SpecialPage
22 */
23
24use MediaWiki\Auth\AuthManager;
25use MediaWiki\Logger\LoggerFactory;
26
27/**
28 * Let users manage bot passwords
29 *
30 * @ingroup SpecialPage
31 */
32class SpecialBotPasswords extends FormSpecialPage {
33
34	/** @var int Central user ID */
35	private $userId = 0;
36
37	/** @var BotPassword|null Bot password being edited, if any */
38	private $botPassword = null;
39
40	/** @var string Operation being performed: create, update, delete */
41	private $operation = null;
42
43	/** @var string New password set, for communication between onSubmit() and onSuccess() */
44	private $password = null;
45
46	/** @var Psr\Log\LoggerInterface */
47	private $logger = null;
48
49	/** @var PasswordFactory */
50	private $passwordFactory;
51
52	/**
53	 * @param PasswordFactory $passwordFactory
54	 * @param AuthManager $authManager
55	 */
56	public function __construct( PasswordFactory $passwordFactory, AuthManager $authManager ) {
57		parent::__construct( 'BotPasswords', 'editmyprivateinfo' );
58		$this->logger = LoggerFactory::getInstance( 'authentication' );
59		$this->passwordFactory = $passwordFactory;
60		$this->setAuthManager( $authManager );
61	}
62
63	/**
64	 * @return bool
65	 */
66	public function isListed() {
67		return $this->getConfig()->get( 'EnableBotPasswords' );
68	}
69
70	protected function getLoginSecurityLevel() {
71		return $this->getName();
72	}
73
74	/**
75	 * Main execution point
76	 * @param string|null $par
77	 */
78	public function execute( $par ) {
79		$this->getOutput()->disallowUserJs();
80		$this->requireLogin();
81		$this->addHelpLink( 'Manual:Bot_passwords' );
82
83		$par = trim( $par );
84		if ( strlen( $par ) === 0 ) {
85			$par = null;
86		} elseif ( strlen( $par ) > BotPassword::APPID_MAXLENGTH ) {
87			throw new ErrorPageError( 'botpasswords', 'botpasswords-bad-appid',
88				[ htmlspecialchars( $par ) ] );
89		}
90
91		parent::execute( $par );
92	}
93
94	protected function checkExecutePermissions( User $user ) {
95		parent::checkExecutePermissions( $user );
96
97		if ( !$this->getConfig()->get( 'EnableBotPasswords' ) ) {
98			throw new ErrorPageError( 'botpasswords', 'botpasswords-disabled' );
99		}
100
101		$this->userId = CentralIdLookup::factory()->centralIdFromLocalUser( $this->getUser() );
102		if ( !$this->userId ) {
103			throw new ErrorPageError( 'botpasswords', 'botpasswords-no-central-id' );
104		}
105	}
106
107	protected function getFormFields() {
108		$fields = [];
109
110		if ( $this->par !== null ) {
111			$this->botPassword = BotPassword::newFromCentralId( $this->userId, $this->par );
112			if ( !$this->botPassword ) {
113				$this->botPassword = BotPassword::newUnsaved( [
114					'centralId' => $this->userId,
115					'appId' => $this->par,
116				] );
117			}
118
119			$sep = BotPassword::getSeparator();
120			$fields[] = [
121				'type' => 'info',
122				'label-message' => 'username',
123				'default' => $this->getUser()->getName() . $sep . $this->par
124			];
125
126			if ( $this->botPassword->isSaved() ) {
127				$fields['resetPassword'] = [
128					'type' => 'check',
129					'label-message' => 'botpasswords-label-resetpassword',
130				];
131				if ( $this->botPassword->isInvalid() ) {
132					$fields['resetPassword']['default'] = true;
133				}
134			}
135
136			$lang = $this->getLanguage();
137			$showGrants = MWGrants::getValidGrants();
138			$grantLinks = array_map( [ MWGrants::class, 'getGrantsLink' ], $showGrants );
139
140			$fields['grants'] = [
141				'type' => 'checkmatrix',
142				'label-message' => 'botpasswords-label-grants',
143				'help-message' => 'botpasswords-help-grants',
144				'columns' => [
145					$this->msg( 'botpasswords-label-grants-column' )->escaped() => 'grant'
146				],
147				'rows' => array_combine(
148					$grantLinks,
149					$showGrants
150				),
151				'default' => array_map(
152					static function ( $g ) {
153						return "grant-$g";
154					},
155					$this->botPassword->getGrants()
156				),
157				'tooltips' => array_combine(
158					$grantLinks,
159					array_map(
160						static function ( $rights ) use ( $lang ) {
161							return $lang->semicolonList( array_map( [ User::class, 'getRightDescription' ], $rights ) );
162						},
163						array_intersect_key( MWGrants::getRightsByGrant(), array_flip( $showGrants ) )
164					)
165				),
166				'force-options-on' => array_map(
167					static function ( $g ) {
168						return "grant-$g";
169					},
170					MWGrants::getHiddenGrants()
171				),
172			];
173
174			$fields['restrictions'] = [
175				'class' => HTMLRestrictionsField::class,
176				'required' => true,
177				'default' => $this->botPassword->getRestrictions(),
178			];
179
180		} else {
181			$linkRenderer = $this->getLinkRenderer();
182
183			$dbr = BotPassword::getDB( DB_REPLICA );
184			$res = $dbr->select(
185				'bot_passwords',
186				[ 'bp_app_id', 'bp_password' ],
187				[ 'bp_user' => $this->userId ],
188				__METHOD__
189			);
190			foreach ( $res as $row ) {
191				try {
192					$password = $this->passwordFactory->newFromCiphertext( $row->bp_password );
193					$passwordInvalid = $password instanceof InvalidPassword;
194					unset( $password );
195				} catch ( PasswordError $ex ) {
196					$passwordInvalid = true;
197				}
198
199				$text = $linkRenderer->makeKnownLink(
200					$this->getPageTitle( $row->bp_app_id ),
201					$row->bp_app_id
202				);
203				if ( $passwordInvalid ) {
204					$text .= $this->msg( 'word-separator' )->escaped()
205						. $this->msg( 'botpasswords-label-needsreset' )->parse();
206				}
207
208				$fields[] = [
209					'section' => 'existing',
210					'type' => 'info',
211					'raw' => true,
212					'default' => $text,
213				];
214			}
215
216			$fields['appId'] = [
217				'section' => 'createnew',
218				'type' => 'textwithbutton',
219				'label-message' => 'botpasswords-label-appid',
220				'buttondefault' => $this->msg( 'botpasswords-label-create' )->text(),
221				'buttonflags' => [ 'progressive', 'primary' ],
222				'required' => true,
223				'size' => BotPassword::APPID_MAXLENGTH,
224				'maxlength' => BotPassword::APPID_MAXLENGTH,
225				'validation-callback' => static function ( $v ) {
226					$v = trim( $v );
227					return $v !== '' && strlen( $v ) <= BotPassword::APPID_MAXLENGTH;
228				},
229			];
230
231			$fields[] = [
232				'type' => 'hidden',
233				'default' => 'new',
234				'name' => 'op',
235			];
236		}
237
238		return $fields;
239	}
240
241	protected function alterForm( HTMLForm $form ) {
242		$form->setId( 'mw-botpasswords-form' );
243		$form->setTableId( 'mw-botpasswords-table' );
244		$form->addPreText( $this->msg( 'botpasswords-summary' )->parseAsBlock() );
245		$form->suppressDefaultSubmit();
246
247		if ( $this->par !== null ) {
248			if ( $this->botPassword->isSaved() ) {
249				$form->setWrapperLegendMsg( 'botpasswords-editexisting' );
250				$form->addButton( [
251					'name' => 'op',
252					'value' => 'update',
253					'label-message' => 'botpasswords-label-update',
254					'flags' => [ 'primary', 'progressive' ],
255				] );
256				$form->addButton( [
257					'name' => 'op',
258					'value' => 'delete',
259					'label-message' => 'botpasswords-label-delete',
260					'flags' => [ 'destructive' ],
261				] );
262			} else {
263				$form->setWrapperLegendMsg( 'botpasswords-createnew' );
264				$form->addButton( [
265					'name' => 'op',
266					'value' => 'create',
267					'label-message' => 'botpasswords-label-create',
268					'flags' => [ 'primary', 'progressive' ],
269				] );
270			}
271
272			$form->addButton( [
273				'name' => 'op',
274				'value' => 'cancel',
275				'label-message' => 'botpasswords-label-cancel'
276			] );
277		}
278	}
279
280	public function onSubmit( array $data ) {
281		$op = $this->getRequest()->getVal( 'op', '' );
282
283		switch ( $op ) {
284			case 'new':
285				$this->getOutput()->redirect( $this->getPageTitle( $data['appId'] )->getFullURL() );
286				return false;
287
288			case 'create':
289				$this->operation = 'insert';
290				return $this->save( $data );
291
292			case 'update':
293				$this->operation = 'update';
294				return $this->save( $data );
295
296			case 'delete':
297				$this->operation = 'delete';
298				$bp = BotPassword::newFromCentralId( $this->userId, $this->par );
299				if ( $bp ) {
300					$bp->delete();
301					$this->logger->info(
302						"Bot password {op} for {user}@{app_id}",
303						[
304							'app_id' => $this->par,
305							'user' => $this->getUser()->getName(),
306							'centralId' => $this->userId,
307							'op' => 'delete',
308							'client_ip' => $this->getRequest()->getIP()
309						]
310					);
311				}
312				return Status::newGood();
313
314			case 'cancel':
315				$this->getOutput()->redirect( $this->getPageTitle()->getFullURL() );
316				return false;
317		}
318
319		return false;
320	}
321
322	private function save( array $data ) {
323		$bp = BotPassword::newUnsaved( [
324			'centralId' => $this->userId,
325			'appId' => $this->par,
326			'restrictions' => $data['restrictions'],
327			'grants' => array_merge(
328				MWGrants::getHiddenGrants(),
329				// @phan-suppress-next-next-line PhanTypeMismatchArgumentInternal See phan issue #3163,
330				// it's probably failing to infer the type of $data['grants']
331				preg_replace( '/^grant-/', '', $data['grants'] )
332			)
333		] );
334
335		if ( $bp === null ) {
336			// Messages: botpasswords-insert-failed, botpasswords-update-failed
337			return Status::newFatal( "botpasswords-{$this->operation}-failed", $this->par );
338		}
339
340		if ( $this->operation === 'insert' || !empty( $data['resetPassword'] ) ) {
341			$this->password = BotPassword::generatePassword( $this->getConfig() );
342			$password = $this->passwordFactory->newFromPlaintext( $this->password );
343		} else {
344			$password = null;
345		}
346
347		$res = $bp->save( $this->operation, $password );
348
349		$success = $res->isGood();
350
351		$this->logger->info(
352			'Bot password {op} for {user}@{app_id} ' . ( $success ? 'succeeded' : 'failed' ),
353			[
354				'op' => $this->operation,
355				'user' => $this->getUser()->getName(),
356				'app_id' => $this->par,
357				'centralId' => $this->userId,
358				'restrictions' => $data['restrictions'],
359				'grants' => $bp->getGrants(),
360				'client_ip' => $this->getRequest()->getIP(),
361				'success' => $success,
362			]
363		);
364
365		return $res;
366	}
367
368	public function onSuccess() {
369		$out = $this->getOutput();
370
371		$username = $this->getUser()->getName();
372		switch ( $this->operation ) {
373			case 'insert':
374				$out->setPageTitle( $this->msg( 'botpasswords-created-title' )->text() );
375				$out->addWikiMsg( 'botpasswords-created-body', $this->par, $username );
376				break;
377
378			case 'update':
379				$out->setPageTitle( $this->msg( 'botpasswords-updated-title' )->text() );
380				$out->addWikiMsg( 'botpasswords-updated-body', $this->par, $username );
381				break;
382
383			case 'delete':
384				$out->setPageTitle( $this->msg( 'botpasswords-deleted-title' )->text() );
385				$out->addWikiMsg( 'botpasswords-deleted-body', $this->par, $username );
386				$this->password = null;
387				break;
388		}
389
390		if ( $this->password !== null ) {
391			$sep = BotPassword::getSeparator();
392			$out->addWikiMsg(
393				'botpasswords-newpassword',
394				htmlspecialchars( $username . $sep . $this->par ),
395				htmlspecialchars( $this->password ),
396				htmlspecialchars( $username ),
397				htmlspecialchars( $this->par . $sep . $this->password )
398			);
399			$this->password = null;
400		}
401
402		$out->addReturnTo( $this->getPageTitle() );
403	}
404
405	protected function getGroupName() {
406		return 'users';
407	}
408
409	protected function getDisplayFormat() {
410		return 'ooui';
411	}
412}
413