Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
51.11% covered (warning)
51.11%
46 / 90
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Pingback
51.69% covered (warning)
51.69%
46 / 89
50.00% covered (danger)
50.00%
4 / 8
76.59
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 run
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
5.02
 wasRecentlySent
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 acquireLock
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getData
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getSystemInfo
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 fetchOrInsertId
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 postPingback
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\Installer;
22
23use BagOStuff;
24use FormatJson;
25use MediaWiki\Config\Config;
26use MediaWiki\Http\HttpRequestFactory;
27use MediaWiki\MainConfigNames;
28use MWCryptRand;
29use Psr\Log\LoggerInterface;
30use Wikimedia\Rdbms\DBError;
31use Wikimedia\Rdbms\IConnectionProvider;
32use Wikimedia\Timestamp\ConvertibleTimestamp;
33
34/**
35 * Send information about this MediaWiki instance to mediawiki.org.
36 *
37 * This service uses two kinds of rows in the `update_log` database table:
38 *
39 * - ul_key `PingBack`, this holds a random identifier for this wiki,
40 *   created only once, when the first ping after wiki creation is sent.
41 * - ul_key `Pingback-<MW_VERSION>`, this holds a timestamp and is created
42 *   once after each MediaWiki upgrade, and then updated up to once a month.
43 *
44 * @internal For use by Setup.php only
45 * @since 1.28
46 */
47class Pingback {
48    /**
49     * @var int Revision ID of the JSON schema that describes the pingback payload.
50     * The schema lives on Meta-Wiki, at <https://meta.wikimedia.org/wiki/Schema:MediaWikiPingback>.
51     */
52    private const SCHEMA_REV = 20104427;
53
54    /** @var LoggerInterface */
55    protected $logger;
56    /** @var Config */
57    protected $config;
58    /** @var IConnectionProvider */
59    protected $dbProvider;
60    /** @var BagOStuff */
61    protected $cache;
62    /** @var HttpRequestFactory */
63    protected $http;
64    /** @var string updatelog key (also used as cache/db lock key) */
65    protected $key;
66
67    /**
68     * @param Config $config
69     * @param IConnectionProvider $dbProvider
70     * @param BagOStuff $cache
71     * @param HttpRequestFactory $http
72     * @param LoggerInterface $logger
73     */
74    public function __construct(
75        Config $config,
76        IConnectionProvider $dbProvider,
77        BagOStuff $cache,
78        HttpRequestFactory $http,
79        LoggerInterface $logger
80    ) {
81        $this->config = $config;
82        $this->dbProvider = $dbProvider;
83        $this->cache = $cache;
84        $this->http = $http;
85        $this->logger = $logger;
86        $this->key = 'Pingback-' . MW_VERSION;
87    }
88
89    /**
90     * Maybe send a ping.
91     *
92     * @throws DBError If identifier insert fails
93     * @throws DBError If timestamp upsert fails
94     */
95    public function run(): void {
96        if ( !$this->config->get( MainConfigNames::Pingback ) ) {
97            // disabled
98            return;
99        }
100        if ( $this->wasRecentlySent() ) {
101            // already sent recently
102            return;
103        }
104        if ( !$this->acquireLock() ) {
105            $this->logger->debug( __METHOD__ . ": couldn't acquire lock" );
106            return;
107        }
108
109        $data = $this->getData();
110        if ( !$this->postPingback( $data ) ) {
111            $this->logger->warning( __METHOD__ . ": failed to send; check 'http' log channel" );
112            return;
113        }
114
115        // Record the fact that we have sent a pingback for this MediaWiki version,
116        // so we don't submit data multiple times.
117        $dbw = $this->dbProvider->getPrimaryDatabase();
118        $timestamp = ConvertibleTimestamp::time();
119        $dbw->newInsertQueryBuilder()
120            ->insertInto( 'updatelog' )
121            ->row( [ 'ul_key' => $this->key, 'ul_value' => $timestamp ] )
122            ->onDuplicateKeyUpdate()
123            ->uniqueIndexFields( [ 'ul_key' ] )
124            ->set( [ 'ul_value' => $timestamp ] )
125            ->caller( __METHOD__ )->execute();
126        $this->logger->debug( __METHOD__ . ": pingback sent OK ({$this->key})" );
127    }
128
129    /**
130     * Was a pingback sent in the last month for this MediaWiki version?
131     *
132     * @return bool
133     */
134    private function wasRecentlySent(): bool {
135        $timestamp = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
136            ->select( 'ul_value' )
137            ->from( 'updatelog' )
138            ->where( [ 'ul_key' => $this->key ] )
139            ->caller( __METHOD__ )->fetchField();
140        if ( $timestamp === false ) {
141            return false;
142        }
143        // send heartbeat ping if the last ping was over a month ago
144        if ( ConvertibleTimestamp::time() - (int)$timestamp > 60 * 60 * 24 * 30 ) {
145            return false;
146        }
147        return true;
148    }
149
150    /**
151     * Acquire lock for sending a pingback
152     *
153     * This ensures only one thread can attempt to send a pingback at any given
154     * time and that we wait an hour before retrying failed attempts.
155     *
156     * @return bool Whether lock was acquired
157     */
158    private function acquireLock(): bool {
159        $cacheKey = $this->cache->makeKey( 'pingback', $this->key );
160        if ( !$this->cache->add( $cacheKey, 1, $this->cache::TTL_HOUR ) ) {
161            // throttled
162            return false;
163        }
164
165        $dbw = $this->dbProvider->getPrimaryDatabase();
166        if ( !$dbw->lock( $this->key, __METHOD__, 0 ) ) {
167            // already in progress
168            return false;
169        }
170
171        return true;
172    }
173
174    /**
175     * Get the EventLogging packet to be sent to the server
176     *
177     * @throws DBError If identifier insert fails
178     * @return array
179     */
180    protected function getData(): array {
181        return [
182            'schema' => 'MediaWikiPingback',
183            'revision' => self::SCHEMA_REV,
184            'wiki' => $this->fetchOrInsertId(),
185            'event' => self::getSystemInfo( $this->config ),
186        ];
187    }
188
189    /**
190     * Collect basic data about this MediaWiki installation and return it
191     * as an associative array conforming to the Pingback schema on Meta-Wiki
192     * (<https://meta.wikimedia.org/wiki/Schema:MediaWikiPingback>).
193     *
194     * Developers: If you're adding a new piece of data to this, please document
195     * this data at <https://www.mediawiki.org/wiki/Manual:$wgPingback>.
196     *
197     * @internal For use by Installer only to display which data we send.
198     * @param Config $config With `DBtype` set.
199     * @return array
200     */
201    public static function getSystemInfo( Config $config ): array {
202        $event = [
203            'database' => $config->get( MainConfigNames::DBtype ),
204            'MediaWiki' => MW_VERSION,
205            'PHP' => PHP_VERSION,
206            'OS' => PHP_OS . ' ' . php_uname( 'r' ),
207            'arch' => PHP_INT_SIZE === 8 ? 64 : 32,
208            'machine' => php_uname( 'm' ),
209        ];
210
211        if ( isset( $_SERVER['SERVER_SOFTWARE'] ) ) {
212            $event['serverSoftware'] = $_SERVER['SERVER_SOFTWARE'];
213        }
214
215        $limit = ini_get( 'memory_limit' );
216        if ( $limit && $limit !== "-1" ) {
217            $event['memoryLimit'] = $limit;
218        }
219
220        return $event;
221    }
222
223    /**
224     * Get a unique, stable identifier for this wiki
225     *
226     * If the identifier does not already exist, create it and save it in the
227     * database. The identifier is randomly-generated.
228     *
229     * @throws DBError If identifier insert fails
230     * @return string 32-character hex string
231     */
232    private function fetchOrInsertId(): string {
233        // We've already obtained a primary connection for the lock, and plan to do a write.
234        // But, still prefer reading this immutable value from a replica to reduce load.
235        $id = $this->dbProvider->getReplicaDatabase()->newSelectQueryBuilder()
236            ->select( 'ul_value' )
237            ->from( 'updatelog' )
238            ->where( [ 'ul_key' => 'PingBack' ] )
239            ->caller( __METHOD__ )->fetchField();
240        if ( $id !== false ) {
241            return $id;
242        }
243
244        $dbw = $this->dbProvider->getPrimaryDatabase();
245        $id = $dbw->newSelectQueryBuilder()
246            ->select( 'ul_value' )
247            ->from( 'updatelog' )
248            ->where( [ 'ul_key' => 'PingBack' ] )
249            ->caller( __METHOD__ )->fetchField();
250        if ( $id !== false ) {
251            return $id;
252        }
253
254        $id = MWCryptRand::generateHex( 32 );
255        $dbw->newInsertQueryBuilder()
256            ->insertInto( 'updatelog' )
257            ->row( [ 'ul_key' => 'PingBack', 'ul_value' => $id ] )
258            ->caller( __METHOD__ )->execute();
259        return $id;
260    }
261
262    /**
263     * Serialize pingback data and send it to mediawiki.org via a POST request
264     * to its EventLogging beacon endpoint.
265     *
266     * The data encoding conforms to the expectations of EventLogging as used by
267     * Wikimedia Foundation for logging and processing analytic data.
268     *
269     * Compare:
270     * <https://gerrit.wikimedia.org/g/mediawiki/extensions/EventLogging/+/7e5fe4f1ef/includes/EventLogging.php#L32>
271     *
272     * The schema for the data is located at:
273     * <https://meta.wikimedia.org/wiki/Schema:MediaWikiPingback>
274     *
275     * @param array $data Pingback data as an associative array
276     * @return bool
277     */
278    private function postPingback( array $data ): bool {
279        $json = FormatJson::encode( $data );
280        $queryString = rawurlencode( str_replace( ' ', '\u0020', $json ) ) . ';';
281        $url = 'https://www.mediawiki.org/beacon/event?' . $queryString;
282        return $this->http->post( $url, [], __METHOD__ ) !== null;
283    }
284}
285
286/** @deprecated class alias since 1.41 */
287class_alias( Pingback::class, 'Pingback' );