Completed
Branch master (f93894)
by
unknown
27:35
created

ApiStashEdit::checkCache()   C

Complexity

Conditions 12
Paths 16

Size

Total Lines 58
Code Lines 38

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 12
eloc 38
nc 16
nop 3
dl 0
loc 58
rs 6.5331
c 1
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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
24
/**
25
 * Prepare an edit in shared cache so that it can be reused on edit
26
 *
27
 * This endpoint can be called via AJAX as the user focuses on the edit
28
 * summary box. By the time of submission, the parse may have already
29
 * finished, and can be immediately used on page save. Certain parser
30
 * functions like {{REVISIONID}} or {{CURRENTTIME}} may cause the cache
31
 * to not be used on edit. Template and files used are check for changes
32
 * since the output was generated. The cache TTL is also kept low for sanity.
33
 *
34
 * @ingroup API
35
 * @since 1.25
36
 */
37
class ApiStashEdit extends ApiBase {
38
	const ERROR_NONE = 'stashed';
39
	const ERROR_PARSE = 'error_parse';
40
	const ERROR_CACHE = 'error_cache';
41
	const ERROR_UNCACHEABLE = 'uncacheable';
42
43
	const PRESUME_FRESH_TTL_SEC = 30;
44
	const MAX_CACHE_TTL = 300; // 5 minutes
45
46
	public function execute() {
47
		$user = $this->getUser();
48
		$params = $this->extractRequestParams();
49
50
		if ( $user->isBot() ) { // sanity
51
			$this->dieUsage( 'This interface is not supported for bots', 'botsnotsupported' );
52
		}
53
54
		$page = $this->getTitleOrPageId( $params );
55
		$title = $page->getTitle();
56
57
		if ( !ContentHandler::getForModelID( $params['contentmodel'] )
58
			->isSupportedFormat( $params['contentformat'] )
59
		) {
60
			$this->dieUsage( 'Unsupported content model/format', 'badmodelformat' );
61
		}
62
63
		// Trim and fix newlines so the key SHA1's match (see RequestContext::getText())
64
		$text = rtrim( str_replace( "\r\n", "\n", $params['text'] ) );
65
		$textContent = ContentHandler::makeContent(
66
			$text, $title, $params['contentmodel'], $params['contentformat'] );
67
68
		$page = WikiPage::factory( $title );
69
		if ( $page->exists() ) {
70
			// Page exists: get the merged content with the proposed change
71
			$baseRev = Revision::newFromPageId( $page->getId(), $params['baserevid'] );
72
			if ( !$baseRev ) {
73
				$this->dieUsage( "No revision ID {$params['baserevid']}", 'missingrev' );
74
			}
75
			$currentRev = $page->getRevision();
76
			if ( !$currentRev ) {
77
				$this->dieUsage( "No current revision of page ID {$page->getId()}", 'missingrev' );
78
			}
79
			// Merge in the new version of the section to get the proposed version
80
			$editContent = $page->replaceSectionAtRev(
81
				$params['section'],
82
				$textContent,
83
				$params['sectiontitle'],
84
				$baseRev->getId()
85
			);
86
			if ( !$editContent ) {
87
				$this->dieUsage( 'Could not merge updated section.', 'replacefailed' );
88
			}
89
			if ( $currentRev->getId() == $baseRev->getId() ) {
90
				// Base revision was still the latest; nothing to merge
91
				$content = $editContent;
92
			} else {
93
				// Merge the edit into the current version
94
				$baseContent = $baseRev->getContent();
95
				$currentContent = $currentRev->getContent();
96
				if ( !$baseContent || !$currentContent ) {
97
					$this->dieUsage( "Missing content for page ID {$page->getId()}", 'missingrev' );
98
				}
99
				$handler = ContentHandler::getForModelID( $baseContent->getModel() );
100
				$content = $handler->merge3( $baseContent, $editContent, $currentContent );
0 ignored issues
show
Bug introduced by
It seems like $editContent defined by $page->replaceSectionAtR...e'], $baseRev->getId()) on line 80 can also be of type null or string; however, ContentHandler::merge3() does only seem to accept object<Content>, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
101
			}
102
		} else {
103
			// New pages: use the user-provided content model
104
			$content = $textContent;
105
		}
106
107
		if ( !$content ) { // merge3() failed
108
			$this->getResult()->addValue( null,
109
				$this->getModuleName(), [ 'status' => 'editconflict' ] );
110
			return;
111
		}
112
113
		// The user will abort the AJAX request by pressing "save", so ignore that
114
		ignore_user_abort( true );
115
116
		// Use the master DB for fast blocking locks
117
		$dbw = wfGetDB( DB_MASTER );
118
119
		// Get a key based on the source text, format, and user preferences
120
		$key = self::getStashKey( $title, $content, $user );
121
		// De-duplicate requests on the same key
122
		if ( $user->pingLimiter( 'stashedit' ) ) {
123
			$status = 'ratelimited';
124
		} elseif ( $dbw->lock( $key, __METHOD__, 1 ) ) {
125
			$status = self::parseAndStash( $page, $content, $user );
126
			$dbw->unlock( $key, __METHOD__ );
127
		} else {
128
			$status = 'busy';
129
		}
130
131
		$this->getStats()->increment( "editstash.cache_stores.$status" );
0 ignored issues
show
Deprecated Code introduced by
The method ContextSource::getStats() has been deprecated with message: since 1.27 use a StatsdDataFactory from MediaWikiServices (preferably injected)

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
132
133
		$this->getResult()->addValue( null, $this->getModuleName(), [ 'status' => $status ] );
134
	}
135
136
	/**
137
	 * @param WikiPage $page
138
	 * @param Content $content
139
	 * @param User $user
140
	 * @return integer ApiStashEdit::ERROR_* constant
141
	 * @since 1.25
142
	 */
143
	public static function parseAndStash( WikiPage $page, Content $content, User $user ) {
144
		$cache = ObjectCache::getLocalClusterInstance();
145
		$logger = LoggerFactory::getInstance( 'StashEdit' );
146
147
		$format = $content->getDefaultFormat();
148
		$editInfo = $page->prepareContentForEdit( $content, null, $user, $format, false );
149
		$title = $page->getTitle();
150
151
		if ( $editInfo && $editInfo->output ) {
152
			$key = self::getStashKey( $title, $content, $user );
153
154
			// Let extensions add ParserOutput metadata or warm other caches
155
			Hooks::run( 'ParserOutputStashForEdit', [ $page, $content, $editInfo->output ] );
156
157
			list( $stashInfo, $ttl, $code ) = self::buildStashValue(
158
				$editInfo->pstContent,
159
				$editInfo->output,
160
				$editInfo->timestamp,
161
				$user
162
			);
163
164
			if ( $stashInfo ) {
165
				$ok = $cache->set( $key, $stashInfo, $ttl );
166
				if ( $ok ) {
167
					$logger->debug( "Cached parser output for key '$key' ('$title')." );
168
					return self::ERROR_NONE;
169
				} else {
170
					$logger->error( "Failed to cache parser output for key '$key' ('$title')." );
171
					return self::ERROR_CACHE;
172
				}
173
			} else {
174
				$logger->info( "Uncacheable parser output for key '$key' ('$title') [$code]." );
175
				return self::ERROR_UNCACHEABLE;
176
			}
177
		}
178
179
		return self::ERROR_PARSE;
180
	}
181
182
	/**
183
	 * Attempt to cache PST content and corresponding parser output in passing
184
	 *
185
	 * This method can be called when the output was already generated for other
186
	 * reasons. Parsing should not be done just to call this method, however.
187
	 * $pstOpts must be that of the user doing the edit preview. If $pOpts does
188
	 * not match the options of WikiPage::makeParserOptions( 'canonical' ), this
189
	 * will do nothing. Provided the values are cacheable, they will be stored
190
	 * in memcached so that final edit submission might make use of them.
191
	 *
192
	 * @param Page|Article|WikiPage $page Page title
193
	 * @param Content $content Proposed page content
194
	 * @param Content $pstContent The result of preSaveTransform() on $content
195
	 * @param ParserOutput $pOut The result of getParserOutput() on $pstContent
196
	 * @param ParserOptions $pstOpts Options for $pstContent (MUST be for prospective author)
197
	 * @param ParserOptions $pOpts Options for $pOut
198
	 * @param string $timestamp TS_MW timestamp of parser output generation
199
	 * @return bool Success
200
	 */
201
	public static function stashEditFromPreview(
202
		Page $page, Content $content, Content $pstContent, ParserOutput $pOut,
203
		ParserOptions $pstOpts, ParserOptions $pOpts, $timestamp
204
	) {
205
		$cache = ObjectCache::getLocalClusterInstance();
206
		$logger = LoggerFactory::getInstance( 'StashEdit' );
207
208
		// getIsPreview() controls parser function behavior that references things
209
		// like user/revision that don't exists yet. The user/text should already
210
		// be set correctly by callers, just double check the preview flag.
211
		if ( !$pOpts->getIsPreview() ) {
212
			return false; // sanity
213
		} elseif ( $pOpts->getIsSectionPreview() ) {
214
			return false; // short-circuit (need the full content)
215
		}
216
217
		// PST parser options are for the user (handles signatures, etc...)
218
		$user = $pstOpts->getUser();
219
		// Get a key based on the source text, format, and user preferences
220
		$title = $page->getTitle();
221
		$key = self::getStashKey( $title, $content, $user );
222
223
		// Parser output options must match cannonical options.
224
		// Treat some options as matching that are different but don't matter.
225
		$canonicalPOpts = $page->makeParserOptions( 'canonical' );
226
		$canonicalPOpts->setIsPreview( true ); // force match
227
		$canonicalPOpts->setTimestamp( $pOpts->getTimestamp() ); // force match
228
		if ( !$pOpts->matches( $canonicalPOpts ) ) {
229
			$logger->info( "Uncacheable preview output for key '$key' ('$title') [options]." );
230
			return false;
231
		}
232
233
		// Set the time the output was generated
234
		$pOut->setCacheTime( wfTimestampNow() );
0 ignored issues
show
Security Bug introduced by
It seems like wfTimestampNow() can also be of type false; however, CacheTime::setCacheTime() does only seem to accept string, did you maybe forget to handle an error condition?
Loading history...
235
236
		// Build a value to cache with a proper TTL
237
		list( $stashInfo, $ttl ) = self::buildStashValue( $pstContent, $pOut, $timestamp, $user );
238
		if ( !$stashInfo ) {
239
			$logger->info( "Uncacheable parser output for key '$key' ('$title') [rev/TTL]." );
240
			return false;
241
		}
242
243
		$ok = $cache->set( $key, $stashInfo, $ttl );
244
		if ( !$ok ) {
245
			$logger->error( "Failed to cache preview parser output for key '$key' ('$title')." );
246
		} else {
247
			$logger->debug( "Cached preview output for key '$key'." );
248
		}
249
250
		return $ok;
251
	}
252
253
	/**
254
	 * Check that a prepared edit is in cache and still up-to-date
255
	 *
256
	 * This method blocks if the prepared edit is already being rendered,
257
	 * waiting until rendering finishes before doing final validity checks.
258
	 *
259
	 * The cache is rejected if template or file changes are detected.
260
	 * Note that foreign template or file transclusions are not checked.
261
	 *
262
	 * The result is a map (pstContent,output,timestamp) with fields
263
	 * extracted directly from WikiPage::prepareContentForEdit().
264
	 *
265
	 * @param Title $title
266
	 * @param Content $content
267
	 * @param User $user User to get parser options from
268
	 * @return stdClass|bool Returns false on cache miss
269
	 */
270
	public static function checkCache( Title $title, Content $content, User $user ) {
271
		if ( $user->isBot() ) {
272
			return false; // bots never stash - don't pollute stats
273
		}
274
275
		$cache = ObjectCache::getLocalClusterInstance();
276
		$logger = LoggerFactory::getInstance( 'StashEdit' );
277
		$stats = RequestContext::getMain()->getStats();
0 ignored issues
show
Deprecated Code introduced by
The method RequestContext::getStats() has been deprecated with message: since 1.27 use a StatsdDataFactory from MediaWikiServices (preferably injected)

This method has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the method will be removed from the class and what other method or class to use instead.

Loading history...
278
279
		$key = self::getStashKey( $title, $content, $user );
280
		$editInfo = $cache->get( $key );
281
		if ( !is_object( $editInfo ) ) {
282
			$start = microtime( true );
283
			// We ignore user aborts and keep parsing. Block on any prior parsing
284
			// so as to use its results and make use of the time spent parsing.
285
			// Skip this logic if there no master connection in case this method
286
			// is called on an HTTP GET request for some reason.
287
			$lb = wfGetLB();
0 ignored issues
show
Deprecated Code introduced by
The function wfGetLB() has been deprecated with message: since 1.27, use MediaWikiServices::getDBLoadBalancer() or MediaWikiServices::getDBLoadBalancerFactory() instead.

This function has been deprecated. The supplier of the file has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed from the class and what other function to use instead.

Loading history...
288
			$dbw = $lb->getAnyOpenConnection( $lb->getWriterIndex() );
289
			if ( $dbw && $dbw->lock( $key, __METHOD__, 30 ) ) {
290
				$editInfo = $cache->get( $key );
291
				$dbw->unlock( $key, __METHOD__ );
292
			}
293
294
			$timeMs = 1000 * max( 0, microtime( true ) - $start );
295
			$stats->timing( 'editstash.lock_wait_time', $timeMs );
296
		}
297
298
		if ( !is_object( $editInfo ) || !$editInfo->output ) {
299
			$stats->increment( 'editstash.cache_misses.no_stash' );
300
			$logger->debug( "Empty cache for key '$key' ('$title'); user '{$user->getName()}'." );
301
			return false;
302
		}
303
304
		$age = time() - wfTimestamp( TS_UNIX, $editInfo->output->getCacheTime() );
305
		if ( $age <= self::PRESUME_FRESH_TTL_SEC ) {
306
			$stats->increment( 'editstash.cache_hits.presumed_fresh' );
307
			$logger->debug( "Timestamp-based cache hit for key '$key' (age: $age sec)." );
308
			return $editInfo; // assume nothing changed
309
		} elseif ( isset( $editInfo->edits ) && $editInfo->edits === $user->getEditCount() ) {
310
			// Logged-in user made no local upload/template edits in the meantime
311
			$stats->increment( 'editstash.cache_hits.presumed_fresh' );
312
			$logger->debug( "Edit count based cache hit for key '$key' (age: $age sec)." );
313
			return $editInfo;
314
		} elseif ( $user->isAnon()
315
			&& self::lastEditTime( $user ) < $editInfo->output->getCacheTime()
316
		) {
317
			// Logged-out user made no local upload/template edits in the meantime
318
			$stats->increment( 'editstash.cache_hits.presumed_fresh' );
319
			$logger->debug( "Edit check based cache hit for key '$key' (age: $age sec)." );
320
			return $editInfo;
321
		}
322
323
		$stats->increment( 'editstash.cache_misses.proven_stale' );
324
		$logger->info( "Stale cache for key '$key'; old key with outside edits. (age: $age sec)" );
325
326
		return false;
327
	}
328
329
	/**
330
	 * @param User $user
331
	 * @return string|null TS_MW timestamp or null
332
	 */
333
	private static function lastEditTime( User $user ) {
334
		$time = wfGetDB( DB_SLAVE )->selectField(
335
			'recentchanges',
336
			'MAX(rc_timestamp)',
337
			[ 'rc_user_text' => $user->getName() ],
338
			__METHOD__
339
		);
340
341
		return wfTimestampOrNull( TS_MW, $time );
342
	}
343
344
	/**
345
	 * Get the temporary prepared edit stash key for a user
346
	 *
347
	 * This key can be used for caching prepared edits provided:
348
	 *   - a) The $user was used for PST options
349
	 *   - b) The parser output was made from the PST using cannonical matching options
350
	 *
351
	 * @param Title $title
352
	 * @param Content $content
353
	 * @param User $user User to get parser options from
354
	 * @return string
355
	 */
356
	private static function getStashKey( Title $title, Content $content, User $user ) {
357
		$hash = sha1( implode( ':', [
358
			// Account for the edit model/text
359
			$content->getModel(),
360
			$content->getDefaultFormat(),
361
			sha1( $content->serialize( $content->getDefaultFormat() ) ),
362
			// Account for user name related variables like signatures
363
			$user->getId(),
364
			md5( $user->getName() )
365
		] ) );
366
367
		return wfMemcKey( 'prepared-edit', md5( $title->getPrefixedDBkey() ), $hash );
368
	}
369
370
	/**
371
	 * Build a value to store in memcached based on the PST content and parser output
372
	 *
373
	 * This makes a simple version of WikiPage::prepareContentForEdit() as stash info
374
	 *
375
	 * @param Content $pstContent
376
	 * @param ParserOutput $parserOutput
377
	 * @param string $timestamp TS_MW
378
	 * @param User $user
379
	 * @return array (stash info array, TTL in seconds, info code) or (null, 0, info code)
380
	 */
381
	private static function buildStashValue(
382
		Content $pstContent, ParserOutput $parserOutput, $timestamp, User $user
383
	) {
384
		// If an item is renewed, mind the cache TTL determined by config and parser functions.
385
		// Put an upper limit on the TTL for sanity to avoid extreme template/file staleness.
386
		$since = time() - wfTimestamp( TS_UNIX, $parserOutput->getTimestamp() );
387
		$ttl = min( $parserOutput->getCacheExpiry() - $since, self::MAX_CACHE_TTL );
388
		if ( $ttl <= 0 ) {
389
			return [ null, 0, 'no_ttl' ];
390
		} elseif ( $parserOutput->getFlag( 'vary-revision' ) ) {
391
			return [ null, 0, 'vary_revision' ];
392
		}
393
394
		// Only store what is actually needed
395
		$stashInfo = (object)[
396
			'pstContent' => $pstContent,
397
			'output'     => $parserOutput,
398
			'timestamp'  => $timestamp,
399
			'edits'      => $user->getEditCount()
400
		];
401
402
		return [ $stashInfo, $ttl, 'ok' ];
403
	}
404
405
	public function getAllowedParams() {
406
		return [
407
			'title' => [
408
				ApiBase::PARAM_TYPE => 'string',
409
				ApiBase::PARAM_REQUIRED => true
410
			],
411
			'section' => [
412
				ApiBase::PARAM_TYPE => 'string',
413
			],
414
			'sectiontitle' => [
415
				ApiBase::PARAM_TYPE => 'string'
416
			],
417
			'text' => [
418
				ApiBase::PARAM_TYPE => 'text',
419
				ApiBase::PARAM_REQUIRED => true
420
			],
421
			'contentmodel' => [
422
				ApiBase::PARAM_TYPE => ContentHandler::getContentModels(),
423
				ApiBase::PARAM_REQUIRED => true
424
			],
425
			'contentformat' => [
426
				ApiBase::PARAM_TYPE => ContentHandler::getAllContentFormats(),
427
				ApiBase::PARAM_REQUIRED => true
428
			],
429
			'baserevid' => [
430
				ApiBase::PARAM_TYPE => 'integer',
431
				ApiBase::PARAM_REQUIRED => true
432
			]
433
		];
434
	}
435
436
	public function needsToken() {
437
		return 'csrf';
438
	}
439
440
	public function mustBePosted() {
441
		return true;
442
	}
443
444
	public function isWriteMode() {
445
		return true;
446
	}
447
448
	public function isInternal() {
449
		return true;
450
	}
451
}
452