jenkins-bot has submitted this change and it was merged.
Change subject: Add support for self-service password resets
......................................................................
Add support for self-service password resets
Add a screen to request a password reset token to be delivered by email
and an associated screen to validate the reset token and allow
a password reset. The recovery tokens issued will automatically expire
on the next password change or in 48 hours if not used.
Bug: T116110
Change-Id: I0eb3aa722954c8cc9bc395f427aee1beaa80c2e1
---
A data/db/migrations/20151020-add-user-reset-token.sql
A data/db/migrations/20151021-add-user-reset-date.sql
M data/db/schema.mysql
M data/i18n/en.json
M data/i18n/qqq.json
A data/templates/account/recover.html
A data/templates/account/reset.html
M data/templates/login.html
M data/templates/nav_login.html
M src/App.php
A src/Controllers/Account/Recover.php
A src/Controllers/Account/Reset.php
M src/Dao/Users.php
M src/Password.php
M tests/PasswordTest.php
15 files changed, 462 insertions(+), 3 deletions(-)
Approvals:
CSteipp: Looks good to me, approved
Siebrand: Looks good to me, but someone else must approve
jenkins-bot: Verified
diff --git a/data/db/migrations/20151020-add-user-reset-token.sql
b/data/db/migrations/20151020-add-user-reset-token.sql
new file mode 100644
index 0000000..1080e94
--- /dev/null
+++ b/data/db/migrations/20151020-add-user-reset-token.sql
@@ -0,0 +1,4 @@
+ALTER TABLE users
+ ADD COLUMN reset_hash VARCHAR(64) DEFAULT NULL
+ AFTER blocked;
+
diff --git a/data/db/migrations/20151021-add-user-reset-date.sql
b/data/db/migrations/20151021-add-user-reset-date.sql
new file mode 100644
index 0000000..1e96ab8
--- /dev/null
+++ b/data/db/migrations/20151021-add-user-reset-date.sql
@@ -0,0 +1,3 @@
+ALTER TABLE users
+ ADD COLUMN reset_date TIMESTAMP NULL DEFAULT NULL
+ AFTER reset_hash;
diff --git a/data/db/schema.mysql b/data/db/schema.mysql
index 8d34383..6f64230 100644
--- a/data/db/schema.mysql
+++ b/data/db/schema.mysql
@@ -10,6 +10,8 @@
, isadmin TINYINT(1) DEFAULT NULL
, viewreports TINYINT(1) DEFAULT NULL
, blocked TINYINT(1) DEFAULT 0
+ , reset_hash VARCHAR(64) DEFAULT NULL
+ , reset_date TIMESTAMP NULL DEFAULT NULL
, PRIMARY KEY (id)
, UNIQUE KEY username (username)
) ENGINE=InnoDB DEFAULT CHARACTER SET=utf8 AUTO_INCREMENT=1000 ;
diff --git a/data/i18n/en.json b/data/i18n/en.json
index da16580..fd7d5db 100644
--- a/data/i18n/en.json
+++ b/data/i18n/en.json
@@ -46,6 +46,20 @@
"login-failed": "Login failed.",
"login-error": "Username and password required.",
+ "forgot-password": "Forgot your password?",
+ "forgot-password-email": "Email address:",
+ "forgot-password-submit": "Send recovery email",
+ "recover-account-subject": "Grants review password recovery",
+ "recover-account-email": "A request was made at $3 to reset the
password for your user account.\n\nRecovery information:\n\nusername:
$1\nrecovery URL: $2\n\nThe recovery URL will expire in 48 hours. If you did
not make this request you can ignore this email. Your account has not been
compromised.\n\nSincerely,\nGrants review administrators",
+ "recover-account-success": "Recovery instructions sent. Check your
email and spam folder.",
+ "recover-account-bad-input": "Invalid input.",
+ "reset-password-bad-token": "Invalid or expired recovery token
supplied.",
+ "reset-password-no-match": "Passwords do not match.",
+ "reset-password-empty": "Password can not be empty.",
+ "reset-password-success": "Password reset successful.",
+ "reset-password-fail": "Password reset failed.",
+ "reset-password-invalid": "Invalid input.",
+
"error-heading": "System error",
"error-message": "An unexpected error has occurred and we are working
to fix the problem. Please try again in a moment.",
"error-id": "Error ID: $1",
diff --git a/data/i18n/qqq.json b/data/i18n/qqq.json
index 272ce4c..35b20c5 100644
--- a/data/i18n/qqq.json
+++ b/data/i18n/qqq.json
@@ -84,6 +84,9 @@
"error-message": "Fatal error page content",
"footer-credits": "Page footer navigation label, links to credits
page.\n{{Identical|Credit}}",
"footer-privacy": "Page footer navigation label, links to privacy
page.\n{{Identical|Privacy}}",
+ "forgot-password": "Link text, links to account recovery page.",
+ "forgot-password-email": "Label for form field",
+ "forgot-password-submit": "Label for form submit button",
"form-conditional": "Radio button label.",
"form-neutral": "Radio button label.",
"form-no": "Radio button label.",
@@ -177,6 +180,16 @@
"report-aggregated-theme": "Report column header",
"report-format-recommend": "Report column value.\n\n* $1 - Number of
reviewers recommending proposal\n* $2 - \"*\" if some recommendations were
conditional\n* $3 - Total number of reviews\n* $4 - Percentage of reviews
recomending approval.",
"proposals-search-campaigns-empty": "Select list usage prompt.",
+ "recover-account-subject": "Email subject",
+ "recover-account-email": "Email body. Parameters:\n* $1 - Account
username\n* $2 - URL to password recovery page of application\n* $3 - URL to
main page of application",
+ "recover-account-success": "Success message for account recovery
request",
+ "recover-account-bad-input": "Error message for account recovery
request",
+ "reset-password-bad-token": "Error message for password reset request",
+ "reset-password-empty": "Error message for password reset request",
+ "reset-password-fail": "Error message for password reset request",
+ "reset-password-invalid": "Error message for password reset request",
+ "reset-password-no-match": "Error message for password reset request",
+ "reset-password-success": "Success message for password reset request",
"reports-wikitext-go": "Form submit button label.",
"review-instructions": "Wikitext formatted form instructions.",
"review-rank-1": "Radio button label.",
diff --git a/data/templates/account/recover.html
b/data/templates/account/recover.html
new file mode 100644
index 0000000..f93a2ca
--- /dev/null
+++ b/data/templates/account/recover.html
@@ -0,0 +1,25 @@
+{% extends "base.html" %}
+
+{% block subtitle %}{{ 'forgot-password'|message }}{% endblock %}
+{% block content %}
+{% spaceless %}
+<ol class="breadcrumb">
+ <li>{{ 'forgot-password'|message }}</li>
+</ol>
+
+<form class="form-horizontal" method="post" action="{{ urlFor(
'account_recover_post' ) }}">
+ <input type="hidden" name="{{ csrf_param }}" value="{{ csrf_token }}" />
+
+ <div class="form-group">
+ <label class="col-sm-2 control-label" for="email">{{
'forgot-password-email'|message }}</label>
+ <div class="col-sm-10">
+ <input type="email" class="form-control" name="email" id="email"
value="" required="required"/>
+ </div>
+ </div>
+
+ <div class="col-sm-10 col-sm-offset-2">
+ <input type="submit" class="btn btn-default" id="save" name="save"
value="{{ 'forgot-password-submit'|message }}"/>
+ </div>
+</form>
+{% endspaceless %}
+{% endblock content %}
diff --git a/data/templates/account/reset.html
b/data/templates/account/reset.html
new file mode 100644
index 0000000..259ccbe
--- /dev/null
+++ b/data/templates/account/reset.html
@@ -0,0 +1,33 @@
+{% extends "base.html" %}
+
+{% block subtitle %}{{ 'change-password'|message }}{% endblock %}
+{% block navbar %}{% endblock %}
+{% block content %}
+{% spaceless %}
+<ol class="breadcrumb">
+ <li>{{ 'change-password'|message }}</li>
+</ol>
+
+<form class="form-horizontal" method="post" action="{{ urlFor(
'account_reset_post', { 'uid': id, 'token': token } ) }}">
+ <input type="hidden" name="{{ csrf_param }}" value="{{ csrf_token }}" />
+
+ <div class="form-group">
+ <label class="col-sm-2 control-label" for="newpw1">{{
'new-password'|message }}</label>
+ <div class="col-sm-10">
+ <input type="password" class="form-control" name="newpw1" id="newpw1"
value="" required="required"/>
+ </div>
+ </div>
+
+ <div class="form-group">
+ <label class="col-sm-2 control-label" for="newpw2">{{
'new-password-again'|message }}</label>
+ <div class="col-sm-10">
+ <input type="password" class="form-control" name="newpw2" id="newpw2"
value="" required="required"/>
+ </div>
+ </div>
+
+ <div class="col-sm-10 col-sm-offset-2">
+ <input type="submit" class="btn btn-default" id="save" name="save"
value="{{ 'save-password'|message }}"/>
+ </div>
+</form>
+{% endspaceless %}
+{% endblock content %}
diff --git a/data/templates/login.html b/data/templates/login.html
index 5b57270..37de577 100644
--- a/data/templates/login.html
+++ b/data/templates/login.html
@@ -31,6 +31,7 @@
</div>
</div>
</form>
+ <p><a href="{{ urlFor( 'account_recover' ) }}">{{
'forgot-password'|message }}</a></p>
</div>
</div>
{% endspaceless %}
diff --git a/data/templates/nav_login.html b/data/templates/nav_login.html
index d3a3579..6348ef9 100644
--- a/data/templates/nav_login.html
+++ b/data/templates/nav_login.html
@@ -10,5 +10,8 @@
<input type="password" class="form-control" id="password" name="password"
required="required" placeholder="{{ 'password'|message }}"/>
</div>
<input type="submit" class="btn btn-default" value="{{ 'login'|message }}" />
+ <div class="form-group">
+ <a href="{{ urlFor( 'account_recover' ) }}">{{ 'forgot-password'|message
}}</a>
+ </div>
</form>
{% endblock login_form %}
diff --git a/src/App.php b/src/App.php
index bfe0b29..d4b114c 100644
--- a/src/App.php
+++ b/src/App.php
@@ -439,6 +439,45 @@
}
);
+ // Account management helpers
+ $slim->group( '/account/',
+ $middleware['inject-user'],
+ function () use ( $slim, $middleware ) {
+ $slim->get( 'recover',
$middleware['must-revalidate'],
+ function () use ( $slim ) {
+ $page = new
Controllers\Account\Recover( $slim );
+ $page->setDao( $slim->usersDao
);
+ $page();
+ }
+ )->name( 'account_recover' );
+
+ $slim->post( 'recover.post',
$middleware['must-revalidate'],
+ function () use ( $slim ) {
+ $page = new
Controllers\Account\Recover( $slim );
+ $page->setDao( $slim->usersDao
);
+ $page->setMailer( $slim->mailer
);
+ $page();
+ }
+ )->name( 'account_recover_post' );
+
+ $slim->get( 'reset/:token/:uid',
$middleware['must-revalidate'],
+ function ( $token, $uid ) use ( $slim )
{
+ $page = new
Controllers\Account\Reset( $slim );
+ $page->setDao( $slim->usersDao
);
+ $page( $uid, $token );
+ }
+ )->name( 'account_reset' );
+
+ $slim->post( 'reset.post/:token/:uid',
$middleware['must-revalidate'],
+ function ( $token, $uid ) use ( $slim )
{
+ $page = new
Controllers\Account\Reset( $slim );
+ $page->setDao( $slim->usersDao
);
+ $page( $uid, $token );
+ }
+ )->name( 'account_reset_post' );
+ }
+ );
+
// Routes for authenticated users
$slim->group( '/user/',
$middleware['must-revalidate'],
diff --git a/src/Controllers/Account/Recover.php
b/src/Controllers/Account/Recover.php
new file mode 100644
index 0000000..481f446
--- /dev/null
+++ b/src/Controllers/Account/Recover.php
@@ -0,0 +1,92 @@
+<?php
+/**
+ * @section LICENSE
+ * This file is part of Wikimedia Grants Review application.
+ *
+ * Wikimedia Grants Review application is free software: you can
+ * redistribute it and/or modify it under the terms of the GNU General Public
+ * License as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * Wikimedia Grants Review application is distributed in the hope that it
+ * will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
+ * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with Wikimedia Grants Review application. If not, see
+ * <http://www.gnu.org/licenses/>.
+ *
+ * @file
+ * @copyright © 2014 Bryan Davis, Wikimedia Foundation and contributors.
+ */
+
+namespace Wikimedia\IEGReview\Controllers\Account;
+
+use Wikimedia\IEGReview\Controller;
+
+/**
+ * Request an account recovery by providing an email address
+ *
+ * @author Bryan Davis <[email protected]>
+ * @copyright © 2014 Bryan Davis, Wikimedia Foundation and contributors.
+ */
+class Recover extends Controller {
+
+ protected function handleGet() {
+ $this->render( 'account/recover.html' );
+ }
+
+
+ protected function handlePost() {
+ $this->form->requireEmail( 'email' );
+
+ $dest = $this->urlFor( 'login' );
+ if ( $this->form->validate() ) {
+ $email = $this->form->get( 'email' );
+ $data = $this->dao->createPasswordResetToken( $email );
+ foreach ( $data as $result ) {
+ list( $token, $user ) = $result;
+ if ( $token !== false ) {
+ $this->sendEmail( $user, $token );
+ }
+ }
+ $this->flash( 'info',
+ $this->i18nContext->message(
'recover-account-success' )
+ );
+ } else {
+ $this->flash( 'error',
+ $this->i18nContext->message(
'recover-account-bad-input' )
+ );
+ $dest = $this->urlFor( 'account_recover' );
+ }
+
+ $this->redirect( $dest );
+ }
+
+ protected function sendEmail( $user, $token ) {
+ $sent = $this->mailer->mail(
+ $user['email'],
+ $this->i18nContext->message( 'recover-account-subject'
),
+ $this->i18nContext->message( 'recover-account-email',
array(
+ $user['username'],
+ $this->request->getUrl() .
+ $this->urlFor( 'account_reset', array(
+ 'uid' => $user['id'],
+ 'token' => $token,
+ ) ),
+ $this->request->getUrl() . $this->urlFor(
'home' ),
+ ) )
+ );
+
+ if ( !$sent ) {
+ $this->logger->error(
+ 'Failed to send reset email for user',
+ array(
+ 'method' => __METHOD__,
+ 'user' => $user['id'],
+ ) );
+ }
+ }
+
+}
diff --git a/src/Controllers/Account/Reset.php
b/src/Controllers/Account/Reset.php
new file mode 100644
index 0000000..8168350
--- /dev/null
+++ b/src/Controllers/Account/Reset.php
@@ -0,0 +1,88 @@
+<?php
+/**
+ * @section LICENSE
+ * This file is part of Wikimedia Grants Review application.
+ *
+ * Wikimedia Grants Review application is free software: you can
+ * redistribute it and/or modify it under the terms of the GNU General Public
+ * License as published by the Free Software Foundation, either version 3 of
+ * the License, or (at your option) any later version.
+ *
+ * Wikimedia Grants Review application is distributed in the hope that it
+ * will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
+ * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with Wikimedia Grants Review application. If not, see
+ * <http://www.gnu.org/licenses/>.
+ *
+ * @file
+ * @copyright © 2014 Bryan Davis, Wikimedia Foundation and contributors.
+ */
+
+namespace Wikimedia\IEGReview\Controllers\Account;
+
+use Wikimedia\IEGReview\Controller;
+
+/**
+ * Reset password using recovery token
+ *
+ * @author Bryan Davis <[email protected]>
+ * @copyright © 2014 Bryan Davis, Wikimedia Foundation and contributors.
+ */
+class Reset extends Controller {
+
+ protected function handleGet( $id, $token ) {
+ if ( $this->dao->validatePasswordResetToken( $id, $token ) ) {
+ $this->view->set( 'id', $id );
+ $this->view->set( 'token', $token );
+ $this->render( 'account/reset.html' );
+
+ } else {
+ $this->flash( 'error',
+ $this->i18nContext->message(
'reset-password-bad-token' ) );
+ $this->redirect( $this->urlFor( 'account_recover' ) );
+ }
+ }
+
+
+ protected function handlePost( $id, $token ) {
+ $this->form->requireString( 'newpw1' );
+ $this->form->requireString( 'newpw2' );
+
+ $dest = $this->urlFor( 'account_reset', array(
+ 'uid' => $id,
+ 'token' => $token,
+ ) );
+
+ if ( $this->form->validate() ) {
+ $newPass = $this->form->get( 'newpw1' );
+
+ if ( $newPass !== $this->form->get( 'newpw2' ) ) {
+ $this->flash( 'error',
+ $this->i18nContext->message(
'reset-password-no-match' ) );
+
+ } elseif ( empty( $newPass ) ) {
+ $this->flash( 'error',
+ $this->i18nContext->message(
'reset-password-empty' ) );
+
+ } else {
+ if ( $this->dao->resetPassword( $id, $token,
$newPass ) ) {
+ $this->flash( 'info',
+ $this->i18nContext->message(
'reset-password-success' ) );
+ $dest = $this->urlFor( 'login' );
+ } else {
+ $this->flash( 'error',
+ $this->i18nContext->message(
'reset-password-fail' ) );
+ }
+ }
+ } else {
+ $this->flash( 'error',
+ $this->i18nContext->message(
'reset-password-invalid' ) );
+ }
+
+ $this->redirect( $dest );
+ }
+
+}
diff --git a/src/Dao/Users.php b/src/Dao/Users.php
index 960c50d..52ab4cd 100644
--- a/src/Dao/Users.php
+++ b/src/Dao/Users.php
@@ -194,9 +194,11 @@
$stmt->execute( array( Password::encodePassword( $newpw
), $id ) );
$this->dbh->commit();
$this->logger->notice( 'Changed password for user',
array(
- 'method' => __METHOD__,
- 'user' => $id,
+ 'method' => __METHOD__,
+ 'user' => $id,
) );
+ // Invalidate any password reset token that may have
been issued
+ $this->updatePasswordResetHash( $id, null );
return true;
} catch ( PDOException $e) {
@@ -243,4 +245,92 @@
return $this->update( $sql, $params );
}
+ /**
+ * Generate password reset token(s) for the given email address.
+ *
+ * @param string $email Email address
+ * @return array (token, user) pairs; token === false on error
+ */
+ public function createPasswordResetToken( $email ) {
+ $ret = array();
+ $users = $this->search( array(
+ 'email' => $email,
+ 'items' => 'all',
+ ) );
+ foreach ( $users->rows as $user ) {
+ $token = bin2hex( Password::getBytes( 16, true ) );
+ $hash = hash( 'sha256', $token );
+ if ( !$this->updatePasswordResetHash( $user['id'],
$hash ) ) {
+ $token = false;
+ }
+ $ret[] = array( $token, $user );
+ }
+ return $ret;
+ }
+
+ protected function updatePasswordResetHash( $id, $hash ) {
+ $ret = false;
+ $stmt = $this->dbh->prepare(
+ 'UPDATE users SET reset_hash = ?, reset_date = now()
WHERE id = ?'
+ );
+ try {
+ $this->dbh->beginTransaction();
+ $stmt->execute( array( $hash, $id ) );
+ $this->dbh->commit();
+ $this->logger->notice( 'Created reset token for user',
array(
+ 'method' => __METHOD__,
+ 'user' => $id,
+ ) );
+ $ret = true;
+
+ } catch ( PDOException $e) {
+ $this->dbh->rollback();
+ $this->logger->error(
+ 'Failed to update reset_hash for user',
+ array(
+ 'method' => __METHOD__,
+ 'exception' => $e,
+ ) );
+ }
+ return $ret;
+ }
+
+ /**
+ * Validate a user's password reset token.
+ *
+ * @param int $id User id
+ * @param string $token Reset token
+ * @return bool
+ */
+ public function validatePasswordResetToken( $id, $token ) {
+ $hash = hash( 'sha256', $token );
+ $row = $this->fetch(
+ 'SELECT reset_hash, reset_date FROM users WHERE id = ?',
+ array( $id )
+ );
+ return $row &&
+ Password::hashEquals( $row['reset_hash'], $hash ) &&
+ // Tokens are only good for 48 hours
+ ( time() - strtotime( $row['reset_date'] ) ) < 172800;
+ }
+
+ /**
+ * Reset a user's password after validating the reset token.
+ *
+ * @param int $id User id
+ * @param string $token Reset token
+ * @param string $pass New password
+ * @return bool
+ */
+ public function resetPassword( $id, $token, $pass ) {
+ $ret = false;
+ if ( $this->validatePasswordResetToken( $id, $token ) ) {
+ $ret = $this->updatePassword( null, $pass, $id, true );
+ if ( $ret ) {
+ // Consume token if change was successful
+ $this->updatePasswordResetHash( $id, null );
+ }
+ }
+ return $ret;
+ }
}
diff --git a/src/Password.php b/src/Password.php
index 589aac8..0a86fb9 100644
--- a/src/Password.php
+++ b/src/Password.php
@@ -54,7 +54,7 @@
$check = md5( $plainText );
}
- return $check === $hash;
+ return self::hashEquals( $hash, $check );
}
@@ -243,6 +243,46 @@
/**
+ * Check whether a user-provided string is equal to a fixed-length
secret
+ * string without revealing bytes of the secret string through timing
+ * differences.
+ *
+ * Implementation for PHP deployments which do not natively have
+ * hash_equals taken from MediaWiki's hash_equals() polyfill function.
+ *
+ * @param string $known Fixed-length secret string to compare against
+ * @param string $input User-provided string
+ * @return bool True if the strings are the same, false otherwise
+ */
+ public static function hashEquals( $known, $input ) {
+ if ( function_exists( 'hash_equals' ) ) {
+ return hash_equals( $known, $input );
+
+ } else {
+ // hash_equals() polyfill taken from MediaWiki
+ if ( !is_string( $known ) ) {
+ return false;
+ }
+ if ( !is_string( $input ) ) {
+ return false;
+ }
+
+ $len = strlen( $known );
+ if ( $len !== strlen( $input ) ) {
+ return false;
+ }
+
+ $result = 0;
+ for ( $i = 0; $i < $len; $i++ ) {
+ $result |= ord( $known[$i] ) ^ ord( $input[$i]
);
+ }
+
+ return $result === 0;
+ }
+ }
+
+
+ /**
* Construction of utility class is not allowed.
*/
private function __construct() {
diff --git a/tests/PasswordTest.php b/tests/PasswordTest.php
index cb86668..ec5c777 100644
--- a/tests/PasswordTest.php
+++ b/tests/PasswordTest.php
@@ -43,6 +43,7 @@
/**
* @covers ::comparePasswordToHash
+ * @covers ::hashEquals
*/
public function testComparePasswordToHash() {
$enc = Password::encodePassword( 'password' );
@@ -61,4 +62,15 @@
$this->assertEquals( 16, strlen( $p ) );
}
+ /**
+ * @covers ::hashEquals
+ */
+ public function testHashEquals() {
+ $this->assertFalse( Password::hashEquals( false, '' ) );
+ $this->assertFalse( Password::hashEquals( '', false ) );
+ $this->assertFalse( Password::hashEquals( 'a', '' ) );
+ $this->assertFalse( Password::hashEquals( 'a', 'b' ) );
+ $this->assertTrue( Password::hashEquals( 'a', 'a' ) );
+ }
+
} //end PasswordTest
--
To view, visit https://gerrit.wikimedia.org/r/247858
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings
Gerrit-MessageType: merged
Gerrit-Change-Id: I0eb3aa722954c8cc9bc395f427aee1beaa80c2e1
Gerrit-PatchSet: 6
Gerrit-Project: wikimedia/iegreview
Gerrit-Branch: master
Gerrit-Owner: BryanDavis <[email protected]>
Gerrit-Reviewer: BryanDavis <[email protected]>
Gerrit-Reviewer: CSteipp <[email protected]>
Gerrit-Reviewer: Niharika29 <[email protected]>
Gerrit-Reviewer: Siebrand <[email protected]>
Gerrit-Reviewer: Springle <[email protected]>
Gerrit-Reviewer: jenkins-bot <>
_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits