Completed
Branch master (246348)
by
unknown
22:34
created

ApiStashEdit::stashEditFromPreview()   B

Complexity

Conditions 6
Paths 6

Size

Total Lines 51
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 29
c 1
b 0
f 0
nc 6
nop 7
dl 0
loc 51
rs 8.6588

How to fix   Long Method   

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, $params['summary'] );
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 Edit content
139
	 * @param User $user
140
	 * @param string $summary Edit summary
141
	 * @return integer ApiStashEdit::ERROR_* constant
142
	 * @since 1.25
143
	 */
144
	public static function parseAndStash( WikiPage $page, Content $content, User $user, $summary ) {
145
		$cache = ObjectCache::getLocalClusterInstance();
146
		$logger = LoggerFactory::getInstance( 'StashEdit' );
147
148
		$format = $content->getDefaultFormat();
149
		$editInfo = $page->prepareContentForEdit( $content, null, $user, $format, false );
150
		$title = $page->getTitle();
151
152
		if ( $editInfo && $editInfo->output ) {
153
			$key = self::getStashKey( $title, $content, $user );
154
155
			// Let extensions add ParserOutput metadata or warm other caches
156
			Hooks::run( 'ParserOutputStashForEdit',
157
				[ $page, $content, $editInfo->output, $summary, $user ] );
158
159
			list( $stashInfo, $ttl, $code ) = self::buildStashValue(
160
				$editInfo->pstContent,
161
				$editInfo->output,
162
				$editInfo->timestamp,
163
				$user
164
			);
165
166
			if ( $stashInfo ) {
167
				$ok = $cache->set( $key, $stashInfo, $ttl );
168
				if ( $ok ) {
169
					$logger->debug( "Cached parser output for key '$key' ('$title')." );
170
					return self::ERROR_NONE;
171
				} else {
172
					$logger->error( "Failed to cache parser output for key '$key' ('$title')." );
173
					return self::ERROR_CACHE;
174
				}
175
			} else {
176
				$logger->info( "Uncacheable parser output for key '$key' ('$title') [$code]." );
177
				return self::ERROR_UNCACHEABLE;
178
			}
179
		}
180
181
		return self::ERROR_PARSE;
182
	}
183
184
	/**
185
	 * Check that a prepared edit is in cache and still up-to-date
186
	 *
187
	 * This method blocks if the prepared edit is already being rendered,
188
	 * waiting until rendering finishes before doing final validity checks.
189
	 *
190
	 * The cache is rejected if template or file changes are detected.
191
	 * Note that foreign template or file transclusions are not checked.
192
	 *
193
	 * The result is a map (pstContent,output,timestamp) with fields
194
	 * extracted directly from WikiPage::prepareContentForEdit().
195
	 *
196
	 * @param Title $title
197
	 * @param Content $content
198
	 * @param User $user User to get parser options from
199
	 * @return stdClass|bool Returns false on cache miss
200
	 */
201
	public static function checkCache( Title $title, Content $content, User $user ) {
202
		if ( $user->isBot() ) {
203
			return false; // bots never stash - don't pollute stats
204
		}
205
206
		$cache = ObjectCache::getLocalClusterInstance();
207
		$logger = LoggerFactory::getInstance( 'StashEdit' );
208
		$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...
209
210
		$key = self::getStashKey( $title, $content, $user );
211
		$editInfo = $cache->get( $key );
212
		if ( !is_object( $editInfo ) ) {
213
			$start = microtime( true );
214
			// We ignore user aborts and keep parsing. Block on any prior parsing
215
			// so as to use its results and make use of the time spent parsing.
216
			// Skip this logic if there no master connection in case this method
217
			// is called on an HTTP GET request for some reason.
218
			$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...
219
			$dbw = $lb->getAnyOpenConnection( $lb->getWriterIndex() );
220
			if ( $dbw && $dbw->lock( $key, __METHOD__, 30 ) ) {
221
				$editInfo = $cache->get( $key );
222
				$dbw->unlock( $key, __METHOD__ );
223
			}
224
225
			$timeMs = 1000 * max( 0, microtime( true ) - $start );
226
			$stats->timing( 'editstash.lock_wait_time', $timeMs );
227
		}
228
229
		if ( !is_object( $editInfo ) || !$editInfo->output ) {
230
			$stats->increment( 'editstash.cache_misses.no_stash' );
231
			$logger->debug( "Empty cache for key '$key' ('$title'); user '{$user->getName()}'." );
232
			return false;
233
		}
234
235
		$age = time() - wfTimestamp( TS_UNIX, $editInfo->output->getCacheTime() );
236
		if ( $age <= self::PRESUME_FRESH_TTL_SEC ) {
237
			$stats->increment( 'editstash.cache_hits.presumed_fresh' );
238
			$logger->debug( "Timestamp-based cache hit for key '$key' (age: $age sec)." );
239
			return $editInfo; // assume nothing changed
240
		} elseif ( isset( $editInfo->edits ) && $editInfo->edits === $user->getEditCount() ) {
241
			// Logged-in user made no local upload/template edits in the meantime
242
			$stats->increment( 'editstash.cache_hits.presumed_fresh' );
243
			$logger->debug( "Edit count based cache hit for key '$key' (age: $age sec)." );
244
			return $editInfo;
245
		} elseif ( $user->isAnon()
246
			&& self::lastEditTime( $user ) < $editInfo->output->getCacheTime()
247
		) {
248
			// Logged-out user made no local upload/template edits in the meantime
249
			$stats->increment( 'editstash.cache_hits.presumed_fresh' );
250
			$logger->debug( "Edit check based cache hit for key '$key' (age: $age sec)." );
251
			return $editInfo;
252
		}
253
254
		$stats->increment( 'editstash.cache_misses.proven_stale' );
255
		$logger->info( "Stale cache for key '$key'; old key with outside edits. (age: $age sec)" );
256
257
		return false;
258
	}
259
260
	/**
261
	 * @param User $user
262
	 * @return string|null TS_MW timestamp or null
263
	 */
264
	private static function lastEditTime( User $user ) {
265
		$time = wfGetDB( DB_SLAVE )->selectField(
266
			'recentchanges',
267
			'MAX(rc_timestamp)',
268
			[ 'rc_user_text' => $user->getName() ],
269
			__METHOD__
270
		);
271
272
		return wfTimestampOrNull( TS_MW, $time );
273
	}
274
275
	/**
276
	 * Get the temporary prepared edit stash key for a user
277
	 *
278
	 * This key can be used for caching prepared edits provided:
279
	 *   - a) The $user was used for PST options
280
	 *   - b) The parser output was made from the PST using cannonical matching options
281
	 *
282
	 * @param Title $title
283
	 * @param Content $content
284
	 * @param User $user User to get parser options from
285
	 * @return string
286
	 */
287
	private static function getStashKey( Title $title, Content $content, User $user ) {
288
		$hash = sha1( implode( ':', [
289
			// Account for the edit model/text
290
			$content->getModel(),
291
			$content->getDefaultFormat(),
292
			sha1( $content->serialize( $content->getDefaultFormat() ) ),
293
			// Account for user name related variables like signatures
294
			$user->getId(),
295
			md5( $user->getName() )
296
		] ) );
297
298
		return wfMemcKey( 'prepared-edit', md5( $title->getPrefixedDBkey() ), $hash );
299
	}
300
301
	/**
302
	 * Build a value to store in memcached based on the PST content and parser output
303
	 *
304
	 * This makes a simple version of WikiPage::prepareContentForEdit() as stash info
305
	 *
306
	 * @param Content $pstContent
307
	 * @param ParserOutput $parserOutput
308
	 * @param string $timestamp TS_MW
309
	 * @param User $user
310
	 * @return array (stash info array, TTL in seconds, info code) or (null, 0, info code)
311
	 */
312
	private static function buildStashValue(
313
		Content $pstContent, ParserOutput $parserOutput, $timestamp, User $user
314
	) {
315
		// If an item is renewed, mind the cache TTL determined by config and parser functions.
316
		// Put an upper limit on the TTL for sanity to avoid extreme template/file staleness.
317
		$since = time() - wfTimestamp( TS_UNIX, $parserOutput->getTimestamp() );
318
		$ttl = min( $parserOutput->getCacheExpiry() - $since, self::MAX_CACHE_TTL );
319
		if ( $ttl <= 0 ) {
320
			return [ null, 0, 'no_ttl' ];
321
		} elseif ( $parserOutput->getFlag( 'vary-revision' ) ) {
322
			return [ null, 0, 'vary_revision' ];
323
		}
324
325
		// Only store what is actually needed
326
		$stashInfo = (object)[
327
			'pstContent' => $pstContent,
328
			'output'     => $parserOutput,
329
			'timestamp'  => $timestamp,
330
			'edits'      => $user->getEditCount()
331
		];
332
333
		return [ $stashInfo, $ttl, 'ok' ];
334
	}
335
336
	public function getAllowedParams() {
337
		return [
338
			'title' => [
339
				ApiBase::PARAM_TYPE => 'string',
340
				ApiBase::PARAM_REQUIRED => true
341
			],
342
			'section' => [
343
				ApiBase::PARAM_TYPE => 'string',
344
			],
345
			'sectiontitle' => [
346
				ApiBase::PARAM_TYPE => 'string'
347
			],
348
			'text' => [
349
				ApiBase::PARAM_TYPE => 'text',
350
				ApiBase::PARAM_REQUIRED => true
351
			],
352
			'summary' => [
353
				ApiBase::PARAM_TYPE => 'string',
354
			],
355
			'contentmodel' => [
356
				ApiBase::PARAM_TYPE => ContentHandler::getContentModels(),
357
				ApiBase::PARAM_REQUIRED => true
358
			],
359
			'contentformat' => [
360
				ApiBase::PARAM_TYPE => ContentHandler::getAllContentFormats(),
361
				ApiBase::PARAM_REQUIRED => true
362
			],
363
			'baserevid' => [
364
				ApiBase::PARAM_TYPE => 'integer',
365
				ApiBase::PARAM_REQUIRED => true
366
			]
367
		];
368
	}
369
370
	public function needsToken() {
371
		return 'csrf';
372
	}
373
374
	public function mustBePosted() {
375
		return true;
376
	}
377
378
	public function isWriteMode() {
379
		return true;
380
	}
381
382
	public function isInternal() {
383
		return true;
384
	}
385
}
386