ApiStashEdit::lastEditTime()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 7
nc 1
nop 1
dl 0
loc 10
rs 9.4285
c 0
b 0
f 0
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
 * @author Aaron Schulz
20
 */
21
22
use MediaWiki\Logger\LoggerFactory;
23
use MediaWiki\MediaWikiServices;
24
use Wikimedia\ScopedCallback;
0 ignored issues
show
Bug introduced by
This use statement conflicts with another class in this namespace, ScopedCallback.

Let’s assume that you have a directory layout like this:

.
|-- OtherDir
|   |-- Bar.php
|   `-- Foo.php
`-- SomeDir
    `-- Foo.php

and let’s assume the following content of Bar.php:

// Bar.php
namespace OtherDir;

use SomeDir\Foo; // This now conflicts the class OtherDir\Foo

If both files OtherDir/Foo.php and SomeDir/Foo.php are loaded in the same runtime, you will see a PHP error such as the following:

PHP Fatal error:  Cannot use SomeDir\Foo as Foo because the name is already in use in OtherDir/Foo.php

However, as OtherDir/Foo.php does not necessarily have to be loaded and the error is only triggered if it is loaded before OtherDir/Bar.php, this problem might go unnoticed for a while. In order to prevent this error from surfacing, you must import the namespace with a different alias:

// Bar.php
namespace OtherDir;

use SomeDir\Foo as SomeDirFoo; // There is no conflict anymore.
Loading history...
25
26
/**
27
 * Prepare an edit in shared cache so that it can be reused on edit
28
 *
29
 * This endpoint can be called via AJAX as the user focuses on the edit
30
 * summary box. By the time of submission, the parse may have already
31
 * finished, and can be immediately used on page save. Certain parser
32
 * functions like {{REVISIONID}} or {{CURRENTTIME}} may cause the cache
33
 * to not be used on edit. Template and files used are check for changes
34
 * since the output was generated. The cache TTL is also kept low for sanity.
35
 *
36
 * @ingroup API
37
 * @since 1.25
38
 */
39
class ApiStashEdit extends ApiBase {
40
	const ERROR_NONE = 'stashed';
41
	const ERROR_PARSE = 'error_parse';
42
	const ERROR_CACHE = 'error_cache';
43
	const ERROR_UNCACHEABLE = 'uncacheable';
44
	const ERROR_BUSY = 'busy';
45
46
	const PRESUME_FRESH_TTL_SEC = 30;
47
	const MAX_CACHE_TTL = 300; // 5 minutes
48
49
	public function execute() {
50
		$user = $this->getUser();
51
		$params = $this->extractRequestParams();
52
53
		if ( $user->isBot() ) { // sanity
54
			$this->dieUsage( 'This interface is not supported for bots', 'botsnotsupported' );
55
		}
56
57
		$cache = ObjectCache::getLocalClusterInstance();
58
		$page = $this->getTitleOrPageId( $params );
59
		$title = $page->getTitle();
60
61
		if ( !ContentHandler::getForModelID( $params['contentmodel'] )
62
			->isSupportedFormat( $params['contentformat'] )
63
		) {
64
			$this->dieUsage( 'Unsupported content model/format', 'badmodelformat' );
65
		}
66
67
		$text = null;
68
		$textHash = null;
69
		if ( strlen( $params['stashedtexthash'] ) ) {
70
			// Load from cache since the client indicates the text is the same as last stash
71
			$textHash = $params['stashedtexthash'];
72
			$textKey = $cache->makeKey( 'stashedit', 'text', $textHash );
73
			$text = $cache->get( $textKey );
74
			if ( !is_string( $text ) ) {
75
				$this->dieUsage( 'No stashed text found with the given hash', 'missingtext' );
76
			}
77
		} elseif ( $params['text'] !== null ) {
78
			// Trim and fix newlines so the key SHA1's match (see WebRequest::getText())
79
			$text = rtrim( str_replace( "\r\n", "\n", $params['text'] ) );
80
			$textHash = sha1( $text );
81
		} else {
82
			$this->dieUsage(
83
				'The text or stashedtexthash parameter must be given', 'missingtextparam' );
84
		}
85
86
		$textContent = ContentHandler::makeContent(
87
			$text, $title, $params['contentmodel'], $params['contentformat'] );
88
89
		$page = WikiPage::factory( $title );
90
		if ( $page->exists() ) {
91
			// Page exists: get the merged content with the proposed change
92
			$baseRev = Revision::newFromPageId( $page->getId(), $params['baserevid'] );
93
			if ( !$baseRev ) {
94
				$this->dieUsage( "No revision ID {$params['baserevid']}", 'missingrev' );
95
			}
96
			$currentRev = $page->getRevision();
97
			if ( !$currentRev ) {
98
				$this->dieUsage( "No current revision of page ID {$page->getId()}", 'missingrev' );
99
			}
100
			// Merge in the new version of the section to get the proposed version
101
			$editContent = $page->replaceSectionAtRev(
102
				$params['section'],
103
				$textContent,
104
				$params['sectiontitle'],
105
				$baseRev->getId()
106
			);
107
			if ( !$editContent ) {
108
				$this->dieUsage( 'Could not merge updated section.', 'replacefailed' );
109
			}
110
			if ( $currentRev->getId() == $baseRev->getId() ) {
111
				// Base revision was still the latest; nothing to merge
112
				$content = $editContent;
113
			} else {
114
				// Merge the edit into the current version
115
				$baseContent = $baseRev->getContent();
116
				$currentContent = $currentRev->getContent();
117
				if ( !$baseContent || !$currentContent ) {
118
					$this->dieUsage( "Missing content for page ID {$page->getId()}", 'missingrev' );
119
				}
120
				$handler = ContentHandler::getForModelID( $baseContent->getModel() );
121
				$content = $handler->merge3( $baseContent, $editContent, $currentContent );
122
			}
123
		} else {
124
			// New pages: use the user-provided content model
125
			$content = $textContent;
126
		}
127
128
		if ( !$content ) { // merge3() failed
129
			$this->getResult()->addValue( null,
130
				$this->getModuleName(), [ 'status' => 'editconflict' ] );
131
			return;
132
		}
133
134
		// The user will abort the AJAX request by pressing "save", so ignore that
135
		ignore_user_abort( true );
136
137
		if ( $user->pingLimiter( 'stashedit' ) ) {
138
			$status = 'ratelimited';
139
		} else {
140
			$status = self::parseAndStash( $page, $content, $user, $params['summary'] );
0 ignored issues
show
Bug introduced by
It seems like $page defined by \WikiPage::factory($title) on line 89 can be null; however, ApiStashEdit::parseAndStash() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
141
			$textKey = $cache->makeKey( 'stashedit', 'text', $textHash );
142
			$cache->set( $textKey, $text, self::MAX_CACHE_TTL );
143
		}
144
145
		$stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
146
		$stats->increment( "editstash.cache_stores.$status" );
147
148
		$this->getResult()->addValue(
149
			null,
150
			$this->getModuleName(),
151
			[
152
				'status' => $status,
153
				'texthash' => $textHash
154
			]
155
		);
156
	}
157
158
	/**
159
	 * @param WikiPage $page
160
	 * @param Content $content Edit content
161
	 * @param User $user
162
	 * @param string $summary Edit summary
163
	 * @return integer ApiStashEdit::ERROR_* constant
164
	 * @since 1.25
165
	 */
166
	public static function parseAndStash( WikiPage $page, Content $content, User $user, $summary ) {
167
		$cache = ObjectCache::getLocalClusterInstance();
168
		$logger = LoggerFactory::getInstance( 'StashEdit' );
169
170
		$title = $page->getTitle();
171
		$key = self::getStashKey( $title, self::getContentHash( $content ), $user );
172
173
		// Use the master DB for fast blocking locks
174
		$dbw = wfGetDB( DB_MASTER );
175
		if ( !$dbw->lock( $key, __METHOD__, 1 ) ) {
176
			// De-duplicate requests on the same key
177
			return self::ERROR_BUSY;
178
		}
179
		/** @noinspection PhpUnusedLocalVariableInspection */
180
		$unlocker = new ScopedCallback( function () use ( $dbw, $key ) {
0 ignored issues
show
Unused Code introduced by
$unlocker is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
181
			$dbw->unlock( $key, __METHOD__ );
182
		} );
183
184
		$cutoffTime = time() - self::PRESUME_FRESH_TTL_SEC;
185
186
		// Reuse any freshly build matching edit stash cache
187
		$editInfo = $cache->get( $key );
188
		if ( $editInfo && wfTimestamp( TS_UNIX, $editInfo->timestamp ) >= $cutoffTime ) {
189
			$alreadyCached = true;
190
		} else {
191
			$format = $content->getDefaultFormat();
192
			$editInfo = $page->prepareContentForEdit( $content, null, $user, $format, false );
193
			$alreadyCached = false;
194
		}
195
196
		if ( $editInfo && $editInfo->output ) {
197
			// Let extensions add ParserOutput metadata or warm other caches
198
			Hooks::run( 'ParserOutputStashForEdit',
199
				[ $page, $content, $editInfo->output, $summary, $user ] );
200
201
			if ( $alreadyCached ) {
202
				$logger->debug( "Already cached parser output for key '$key' ('$title')." );
203
				return self::ERROR_NONE;
204
			}
205
206
			list( $stashInfo, $ttl, $code ) = self::buildStashValue(
207
				$editInfo->pstContent,
208
				$editInfo->output,
209
				$editInfo->timestamp,
210
				$user
211
			);
212
213
			if ( $stashInfo ) {
214
				$ok = $cache->set( $key, $stashInfo, $ttl );
215
				if ( $ok ) {
216
					$logger->debug( "Cached parser output for key '$key' ('$title')." );
217
					return self::ERROR_NONE;
218
				} else {
219
					$logger->error( "Failed to cache parser output for key '$key' ('$title')." );
220
					return self::ERROR_CACHE;
221
				}
222
			} else {
223
				$logger->info( "Uncacheable parser output for key '$key' ('$title') [$code]." );
224
				return self::ERROR_UNCACHEABLE;
225
			}
226
		}
227
228
		return self::ERROR_PARSE;
229
	}
230
231
	/**
232
	 * Check that a prepared edit is in cache and still up-to-date
233
	 *
234
	 * This method blocks if the prepared edit is already being rendered,
235
	 * waiting until rendering finishes before doing final validity checks.
236
	 *
237
	 * The cache is rejected if template or file changes are detected.
238
	 * Note that foreign template or file transclusions are not checked.
239
	 *
240
	 * The result is a map (pstContent,output,timestamp) with fields
241
	 * extracted directly from WikiPage::prepareContentForEdit().
242
	 *
243
	 * @param Title $title
244
	 * @param Content $content
245
	 * @param User $user User to get parser options from
246
	 * @return stdClass|bool Returns false on cache miss
247
	 */
248
	public static function checkCache( Title $title, Content $content, User $user ) {
249
		if ( $user->isBot() ) {
250
			return false; // bots never stash - don't pollute stats
251
		}
252
253
		$cache = ObjectCache::getLocalClusterInstance();
254
		$logger = LoggerFactory::getInstance( 'StashEdit' );
255
		$stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
256
257
		$key = self::getStashKey( $title, self::getContentHash( $content ), $user );
258
		$editInfo = $cache->get( $key );
259
		if ( !is_object( $editInfo ) ) {
260
			$start = microtime( true );
261
			// We ignore user aborts and keep parsing. Block on any prior parsing
262
			// so as to use its results and make use of the time spent parsing.
263
			// Skip this logic if there no master connection in case this method
264
			// is called on an HTTP GET request for some reason.
265
			$lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
266
			$dbw = $lb->getAnyOpenConnection( $lb->getWriterIndex() );
267
			if ( $dbw && $dbw->lock( $key, __METHOD__, 30 ) ) {
268
				$editInfo = $cache->get( $key );
269
				$dbw->unlock( $key, __METHOD__ );
270
			}
271
272
			$timeMs = 1000 * max( 0, microtime( true ) - $start );
273
			$stats->timing( 'editstash.lock_wait_time', $timeMs );
274
		}
275
276
		if ( !is_object( $editInfo ) || !$editInfo->output ) {
277
			$stats->increment( 'editstash.cache_misses.no_stash' );
278
			$logger->debug( "Empty cache for key '$key' ('$title'); user '{$user->getName()}'." );
279
			return false;
280
		}
281
282
		$age = time() - wfTimestamp( TS_UNIX, $editInfo->output->getCacheTime() );
283
		if ( $age <= self::PRESUME_FRESH_TTL_SEC ) {
284
			// Assume nothing changed in this time
285
			$stats->increment( 'editstash.cache_hits.presumed_fresh' );
286
			$logger->debug( "Timestamp-based cache hit for key '$key' (age: $age sec)." );
287
		} elseif ( isset( $editInfo->edits ) && $editInfo->edits === $user->getEditCount() ) {
288
			// Logged-in user made no local upload/template edits in the meantime
289
			$stats->increment( 'editstash.cache_hits.presumed_fresh' );
290
			$logger->debug( "Edit count based cache hit for key '$key' (age: $age sec)." );
291
		} elseif ( $user->isAnon()
292
			&& self::lastEditTime( $user ) < $editInfo->output->getCacheTime()
293
		) {
294
			// Logged-out user made no local upload/template edits in the meantime
295
			$stats->increment( 'editstash.cache_hits.presumed_fresh' );
296
			$logger->debug( "Edit check based cache hit for key '$key' (age: $age sec)." );
297
		} else {
298
			// User may have changed included content
299
			$editInfo = false;
300
		}
301
302
		if ( !$editInfo ) {
303
			$stats->increment( 'editstash.cache_misses.proven_stale' );
304
			$logger->info( "Stale cache for key '$key'; old key with outside edits. (age: $age sec)" );
305
		} elseif ( $editInfo->output->getFlag( 'vary-revision' ) ) {
306
			// This can be used for the initial parse, e.g. for filters or doEditContent(),
307
			// but a second parse will be triggered in doEditUpdates(). This is not optimal.
308
			$logger->info( "Cache for key '$key' ('$title') has vary_revision." );
309
		} elseif ( $editInfo->output->getFlag( 'vary-revision-id' ) ) {
310
			// Similar to the above if we didn't guess the ID correctly.
311
			$logger->info( "Cache for key '$key' ('$title') has vary_revision_id." );
312
		}
313
314
		return $editInfo;
315
	}
316
317
	/**
318
	 * @param User $user
319
	 * @return string|null TS_MW timestamp or null
320
	 */
321
	private static function lastEditTime( User $user ) {
322
		$time = wfGetDB( DB_REPLICA )->selectField(
323
			'recentchanges',
324
			'MAX(rc_timestamp)',
325
			[ 'rc_user_text' => $user->getName() ],
326
			__METHOD__
327
		);
328
329
		return wfTimestampOrNull( TS_MW, $time );
330
	}
331
332
	/**
333
	 * Get hash of the content, factoring in model/format
334
	 *
335
	 * @param Content $content
336
	 * @return string
337
	 */
338
	private static function getContentHash( Content $content ) {
339
		return sha1( implode( "\n", [
340
			$content->getModel(),
341
			$content->getDefaultFormat(),
342
			$content->serialize( $content->getDefaultFormat() )
343
		] ) );
344
	}
345
346
	/**
347
	 * Get the temporary prepared edit stash key for a user
348
	 *
349
	 * This key can be used for caching prepared edits provided:
350
	 *   - a) The $user was used for PST options
351
	 *   - b) The parser output was made from the PST using cannonical matching options
352
	 *
353
	 * @param Title $title
354
	 * @param string $contentHash Result of getContentHash()
355
	 * @param User $user User to get parser options from
356
	 * @return string
357
	 */
358
	private static function getStashKey( Title $title, $contentHash, User $user ) {
359
		return ObjectCache::getLocalClusterInstance()->makeKey(
360
			'prepared-edit',
361
			md5( $title->getPrefixedDBkey() ),
362
			// Account for the edit model/text
363
			$contentHash,
364
			// Account for user name related variables like signatures
365
			md5( $user->getId() . "\n" . $user->getName() )
366
		);
367
	}
368
369
	/**
370
	 * Build a value to store in memcached based on the PST content and parser output
371
	 *
372
	 * This makes a simple version of WikiPage::prepareContentForEdit() as stash info
373
	 *
374
	 * @param Content $pstContent Pre-Save transformed content
375
	 * @param ParserOutput $parserOutput
376
	 * @param string $timestamp TS_MW
377
	 * @param User $user
378
	 * @return array (stash info array, TTL in seconds, info code) or (null, 0, info code)
379
	 */
380
	private static function buildStashValue(
381
		Content $pstContent, ParserOutput $parserOutput, $timestamp, User $user
382
	) {
383
		// If an item is renewed, mind the cache TTL determined by config and parser functions.
384
		// Put an upper limit on the TTL for sanity to avoid extreme template/file staleness.
385
		$since = time() - wfTimestamp( TS_UNIX, $parserOutput->getTimestamp() );
386
		$ttl = min( $parserOutput->getCacheExpiry() - $since, self::MAX_CACHE_TTL );
387
		if ( $ttl <= 0 ) {
388
			return [ null, 0, 'no_ttl' ];
389
		}
390
391
		// Only store what is actually needed
392
		$stashInfo = (object)[
393
			'pstContent' => $pstContent,
394
			'output'     => $parserOutput,
395
			'timestamp'  => $timestamp,
396
			'edits'      => $user->getEditCount()
397
		];
398
399
		return [ $stashInfo, $ttl, 'ok' ];
400
	}
401
402
	public function getAllowedParams() {
403
		return [
404
			'title' => [
405
				ApiBase::PARAM_TYPE => 'string',
406
				ApiBase::PARAM_REQUIRED => true
407
			],
408
			'section' => [
409
				ApiBase::PARAM_TYPE => 'string',
410
			],
411
			'sectiontitle' => [
412
				ApiBase::PARAM_TYPE => 'string'
413
			],
414
			'text' => [
415
				ApiBase::PARAM_TYPE => 'text',
416
				ApiBase::PARAM_DFLT => null
417
			],
418
			'stashedtexthash' => [
419
				ApiBase::PARAM_TYPE => 'string',
420
				ApiBase::PARAM_DFLT => null
421
			],
422
			'summary' => [
423
				ApiBase::PARAM_TYPE => 'string',
424
			],
425
			'contentmodel' => [
426
				ApiBase::PARAM_TYPE => ContentHandler::getContentModels(),
427
				ApiBase::PARAM_REQUIRED => true
428
			],
429
			'contentformat' => [
430
				ApiBase::PARAM_TYPE => ContentHandler::getAllContentFormats(),
431
				ApiBase::PARAM_REQUIRED => true
432
			],
433
			'baserevid' => [
434
				ApiBase::PARAM_TYPE => 'integer',
435
				ApiBase::PARAM_REQUIRED => true
436
			]
437
		];
438
	}
439
440
	public function needsToken() {
441
		return 'csrf';
442
	}
443
444
	public function mustBePosted() {
445
		return true;
446
	}
447
448
	public function isWriteMode() {
449
		return true;
450
	}
451
452
	public function isInternal() {
453
		return true;
454
	}
455
}
456