]> scripts.mit.edu Git - autoinstalls/mediawiki.git/blob - includes/api/ApiStashEdit.php
MediaWiki 1.30.2-scripts
[autoinstalls/mediawiki.git] / includes / api / ApiStashEdit.php
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
21 use MediaWiki\Logger\LoggerFactory;
22 use MediaWiki\MediaWikiServices;
23 use Wikimedia\ScopedCallback;
24
25 /**
26  * Prepare an edit in shared cache so that it can be reused on edit
27  *
28  * This endpoint can be called via AJAX as the user focuses on the edit
29  * summary box. By the time of submission, the parse may have already
30  * finished, and can be immediately used on page save. Certain parser
31  * functions like {{REVISIONID}} or {{CURRENTTIME}} may cause the cache
32  * to not be used on edit. Template and files used are check for changes
33  * since the output was generated. The cache TTL is also kept low for sanity.
34  *
35  * @ingroup API
36  * @since 1.25
37  */
38 class ApiStashEdit extends ApiBase {
39         const ERROR_NONE = 'stashed';
40         const ERROR_PARSE = 'error_parse';
41         const ERROR_CACHE = 'error_cache';
42         const ERROR_UNCACHEABLE = 'uncacheable';
43         const ERROR_BUSY = 'busy';
44
45         const PRESUME_FRESH_TTL_SEC = 30;
46         const MAX_CACHE_TTL = 300; // 5 minutes
47         const MAX_SIGNATURE_TTL = 60;
48
49         public function execute() {
50                 $user = $this->getUser();
51                 $params = $this->extractRequestParams();
52
53                 if ( $user->isBot() ) { // sanity
54                         $this->dieWithError( 'apierror-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->dieWithError(
65                                 [ 'apierror-badformat-generic', $params['contentformat'], $params['contentmodel'] ],
66                                 'badmodelformat'
67                         );
68                 }
69
70                 $this->requireAtLeastOneParameter( $params, 'stashedtexthash', 'text' );
71
72                 $text = null;
73                 $textHash = null;
74                 if ( strlen( $params['stashedtexthash'] ) ) {
75                         // Load from cache since the client indicates the text is the same as last stash
76                         $textHash = $params['stashedtexthash'];
77                         if ( !preg_match( '/^[0-9a-f]{40}$/', $textHash ) ) {
78                                 $this->dieWithError( 'apierror-stashedit-missingtext', 'missingtext' );
79                         }
80                         $textKey = $cache->makeKey( 'stashedit', 'text', $textHash );
81                         $text = $cache->get( $textKey );
82                         if ( !is_string( $text ) ) {
83                                 $this->dieWithError( 'apierror-stashedit-missingtext', 'missingtext' );
84                         }
85                 } elseif ( $params['text'] !== null ) {
86                         // Trim and fix newlines so the key SHA1's match (see WebRequest::getText())
87                         $text = rtrim( str_replace( "\r\n", "\n", $params['text'] ) );
88                         $textHash = sha1( $text );
89                 } else {
90                         $this->dieWithError( [
91                                 'apierror-missingparam-at-least-one-of',
92                                 Message::listParam( [ '<var>stashedtexthash</var>', '<var>text</var>' ] ),
93                                 2,
94                         ], 'missingparam' );
95                 }
96
97                 $textContent = ContentHandler::makeContent(
98                         $text, $title, $params['contentmodel'], $params['contentformat'] );
99
100                 $page = WikiPage::factory( $title );
101                 if ( $page->exists() ) {
102                         // Page exists: get the merged content with the proposed change
103                         $baseRev = Revision::newFromPageId( $page->getId(), $params['baserevid'] );
104                         if ( !$baseRev ) {
105                                 $this->dieWithError( [ 'apierror-nosuchrevid', $params['baserevid'] ] );
106                         }
107                         $currentRev = $page->getRevision();
108                         if ( !$currentRev ) {
109                                 $this->dieWithError( [ 'apierror-missingrev-pageid', $page->getId() ], 'missingrev' );
110                         }
111                         // Merge in the new version of the section to get the proposed version
112                         $editContent = $page->replaceSectionAtRev(
113                                 $params['section'],
114                                 $textContent,
115                                 $params['sectiontitle'],
116                                 $baseRev->getId()
117                         );
118                         if ( !$editContent ) {
119                                 $this->dieWithError( 'apierror-sectionreplacefailed', 'replacefailed' );
120                         }
121                         if ( $currentRev->getId() == $baseRev->getId() ) {
122                                 // Base revision was still the latest; nothing to merge
123                                 $content = $editContent;
124                         } else {
125                                 // Merge the edit into the current version
126                                 $baseContent = $baseRev->getContent();
127                                 $currentContent = $currentRev->getContent();
128                                 if ( !$baseContent || !$currentContent ) {
129                                         $this->dieWithError( [ 'apierror-missingcontent-pageid', $page->getId() ], 'missingrev' );
130                                 }
131                                 $handler = ContentHandler::getForModelID( $baseContent->getModel() );
132                                 $content = $handler->merge3( $baseContent, $editContent, $currentContent );
133                         }
134                 } else {
135                         // New pages: use the user-provided content model
136                         $content = $textContent;
137                 }
138
139                 if ( !$content ) { // merge3() failed
140                         $this->getResult()->addValue( null,
141                                 $this->getModuleName(), [ 'status' => 'editconflict' ] );
142                         return;
143                 }
144
145                 // The user will abort the AJAX request by pressing "save", so ignore that
146                 ignore_user_abort( true );
147
148                 if ( $user->pingLimiter( 'stashedit' ) ) {
149                         $status = 'ratelimited';
150                 } else {
151                         $status = self::parseAndStash( $page, $content, $user, $params['summary'] );
152                         $textKey = $cache->makeKey( 'stashedit', 'text', $textHash );
153                         $cache->set( $textKey, $text, self::MAX_CACHE_TTL );
154                 }
155
156                 $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
157                 $stats->increment( "editstash.cache_stores.$status" );
158
159                 $this->getResult()->addValue(
160                         null,
161                         $this->getModuleName(),
162                         [
163                                 'status' => $status,
164                                 'texthash' => $textHash
165                         ]
166                 );
167         }
168
169         /**
170          * @param WikiPage $page
171          * @param Content $content Edit content
172          * @param User $user
173          * @param string $summary Edit summary
174          * @return string ApiStashEdit::ERROR_* constant
175          * @since 1.25
176          */
177         public static function parseAndStash( WikiPage $page, Content $content, User $user, $summary ) {
178                 $cache = ObjectCache::getLocalClusterInstance();
179                 $logger = LoggerFactory::getInstance( 'StashEdit' );
180
181                 $title = $page->getTitle();
182                 $key = self::getStashKey( $title, self::getContentHash( $content ), $user );
183
184                 // Use the master DB for fast blocking locks
185                 $dbw = wfGetDB( DB_MASTER );
186                 if ( !$dbw->lock( $key, __METHOD__, 1 ) ) {
187                         // De-duplicate requests on the same key
188                         return self::ERROR_BUSY;
189                 }
190                 /** @noinspection PhpUnusedLocalVariableInspection */
191                 $unlocker = new ScopedCallback( function () use ( $dbw, $key ) {
192                         $dbw->unlock( $key, __METHOD__ );
193                 } );
194
195                 $cutoffTime = time() - self::PRESUME_FRESH_TTL_SEC;
196
197                 // Reuse any freshly build matching edit stash cache
198                 $editInfo = $cache->get( $key );
199                 if ( $editInfo && wfTimestamp( TS_UNIX, $editInfo->timestamp ) >= $cutoffTime ) {
200                         $alreadyCached = true;
201                 } else {
202                         $format = $content->getDefaultFormat();
203                         $editInfo = $page->prepareContentForEdit( $content, null, $user, $format, false );
204                         $alreadyCached = false;
205                 }
206
207                 if ( $editInfo && $editInfo->output ) {
208                         // Let extensions add ParserOutput metadata or warm other caches
209                         Hooks::run( 'ParserOutputStashForEdit',
210                                 [ $page, $content, $editInfo->output, $summary, $user ] );
211
212                         if ( $alreadyCached ) {
213                                 $logger->debug( "Already cached parser output for key '$key' ('$title')." );
214                                 return self::ERROR_NONE;
215                         }
216
217                         list( $stashInfo, $ttl, $code ) = self::buildStashValue(
218                                 $editInfo->pstContent,
219                                 $editInfo->output,
220                                 $editInfo->timestamp,
221                                 $user
222                         );
223
224                         if ( $stashInfo ) {
225                                 $ok = $cache->set( $key, $stashInfo, $ttl );
226                                 if ( $ok ) {
227                                         $logger->debug( "Cached parser output for key '$key' ('$title')." );
228                                         return self::ERROR_NONE;
229                                 } else {
230                                         $logger->error( "Failed to cache parser output for key '$key' ('$title')." );
231                                         return self::ERROR_CACHE;
232                                 }
233                         } else {
234                                 $logger->info( "Uncacheable parser output for key '$key' ('$title') [$code]." );
235                                 return self::ERROR_UNCACHEABLE;
236                         }
237                 }
238
239                 return self::ERROR_PARSE;
240         }
241
242         /**
243          * Check that a prepared edit is in cache and still up-to-date
244          *
245          * This method blocks if the prepared edit is already being rendered,
246          * waiting until rendering finishes before doing final validity checks.
247          *
248          * The cache is rejected if template or file changes are detected.
249          * Note that foreign template or file transclusions are not checked.
250          *
251          * The result is a map (pstContent,output,timestamp) with fields
252          * extracted directly from WikiPage::prepareContentForEdit().
253          *
254          * @param Title $title
255          * @param Content $content
256          * @param User $user User to get parser options from
257          * @return stdClass|bool Returns false on cache miss
258          */
259         public static function checkCache( Title $title, Content $content, User $user ) {
260                 if ( $user->isBot() ) {
261                         return false; // bots never stash - don't pollute stats
262                 }
263
264                 $cache = ObjectCache::getLocalClusterInstance();
265                 $logger = LoggerFactory::getInstance( 'StashEdit' );
266                 $stats = MediaWikiServices::getInstance()->getStatsdDataFactory();
267
268                 $key = self::getStashKey( $title, self::getContentHash( $content ), $user );
269                 $editInfo = $cache->get( $key );
270                 if ( !is_object( $editInfo ) ) {
271                         $start = microtime( true );
272                         // We ignore user aborts and keep parsing. Block on any prior parsing
273                         // so as to use its results and make use of the time spent parsing.
274                         // Skip this logic if there no master connection in case this method
275                         // is called on an HTTP GET request for some reason.
276                         $lb = MediaWikiServices::getInstance()->getDBLoadBalancer();
277                         $dbw = $lb->getAnyOpenConnection( $lb->getWriterIndex() );
278                         if ( $dbw && $dbw->lock( $key, __METHOD__, 30 ) ) {
279                                 $editInfo = $cache->get( $key );
280                                 $dbw->unlock( $key, __METHOD__ );
281                         }
282
283                         $timeMs = 1000 * max( 0, microtime( true ) - $start );
284                         $stats->timing( 'editstash.lock_wait_time', $timeMs );
285                 }
286
287                 if ( !is_object( $editInfo ) || !$editInfo->output ) {
288                         $stats->increment( 'editstash.cache_misses.no_stash' );
289                         $logger->debug( "Empty cache for key '$key' ('$title'); user '{$user->getName()}'." );
290                         return false;
291                 }
292
293                 $age = time() - wfTimestamp( TS_UNIX, $editInfo->output->getCacheTime() );
294                 if ( $age <= self::PRESUME_FRESH_TTL_SEC ) {
295                         // Assume nothing changed in this time
296                         $stats->increment( 'editstash.cache_hits.presumed_fresh' );
297                         $logger->debug( "Timestamp-based cache hit for key '$key' (age: $age sec)." );
298                 } elseif ( isset( $editInfo->edits ) && $editInfo->edits === $user->getEditCount() ) {
299                         // Logged-in user made no local upload/template edits in the meantime
300                         $stats->increment( 'editstash.cache_hits.presumed_fresh' );
301                         $logger->debug( "Edit count based cache hit for key '$key' (age: $age sec)." );
302                 } elseif ( $user->isAnon()
303                         && self::lastEditTime( $user ) < $editInfo->output->getCacheTime()
304                 ) {
305                         // Logged-out user made no local upload/template edits in the meantime
306                         $stats->increment( 'editstash.cache_hits.presumed_fresh' );
307                         $logger->debug( "Edit check based cache hit for key '$key' (age: $age sec)." );
308                 } else {
309                         // User may have changed included content
310                         $editInfo = false;
311                 }
312
313                 if ( !$editInfo ) {
314                         $stats->increment( 'editstash.cache_misses.proven_stale' );
315                         $logger->info( "Stale cache for key '$key'; old key with outside edits. (age: $age sec)" );
316                 } elseif ( $editInfo->output->getFlag( 'vary-revision' ) ) {
317                         // This can be used for the initial parse, e.g. for filters or doEditContent(),
318                         // but a second parse will be triggered in doEditUpdates(). This is not optimal.
319                         $logger->info( "Cache for key '$key' ('$title') has vary_revision." );
320                 } elseif ( $editInfo->output->getFlag( 'vary-revision-id' ) ) {
321                         // Similar to the above if we didn't guess the ID correctly.
322                         $logger->info( "Cache for key '$key' ('$title') has vary_revision_id." );
323                 }
324
325                 return $editInfo;
326         }
327
328         /**
329          * @param User $user
330          * @return string|null TS_MW timestamp or null
331          */
332         private static function lastEditTime( User $user ) {
333                 $time = wfGetDB( DB_REPLICA )->selectField(
334                         'recentchanges',
335                         'MAX(rc_timestamp)',
336                         [ 'rc_user_text' => $user->getName() ],
337                         __METHOD__
338                 );
339
340                 return wfTimestampOrNull( TS_MW, $time );
341         }
342
343         /**
344          * Get hash of the content, factoring in model/format
345          *
346          * @param Content $content
347          * @return string
348          */
349         private static function getContentHash( Content $content ) {
350                 return sha1( implode( "\n", [
351                         $content->getModel(),
352                         $content->getDefaultFormat(),
353                         $content->serialize( $content->getDefaultFormat() )
354                 ] ) );
355         }
356
357         /**
358          * Get the temporary prepared edit stash key for a user
359          *
360          * This key can be used for caching prepared edits provided:
361          *   - a) The $user was used for PST options
362          *   - b) The parser output was made from the PST using cannonical matching options
363          *
364          * @param Title $title
365          * @param string $contentHash Result of getContentHash()
366          * @param User $user User to get parser options from
367          * @return string
368          */
369         private static function getStashKey( Title $title, $contentHash, User $user ) {
370                 return ObjectCache::getLocalClusterInstance()->makeKey(
371                         'prepared-edit',
372                         md5( $title->getPrefixedDBkey() ),
373                         // Account for the edit model/text
374                         $contentHash,
375                         // Account for user name related variables like signatures
376                         md5( $user->getId() . "\n" . $user->getName() )
377                 );
378         }
379
380         /**
381          * Build a value to store in memcached based on the PST content and parser output
382          *
383          * This makes a simple version of WikiPage::prepareContentForEdit() as stash info
384          *
385          * @param Content $pstContent Pre-Save transformed content
386          * @param ParserOutput $parserOutput
387          * @param string $timestamp TS_MW
388          * @param User $user
389          * @return array (stash info array, TTL in seconds, info code) or (null, 0, info code)
390          */
391         private static function buildStashValue(
392                 Content $pstContent, ParserOutput $parserOutput, $timestamp, User $user
393         ) {
394                 // If an item is renewed, mind the cache TTL determined by config and parser functions.
395                 // Put an upper limit on the TTL for sanity to avoid extreme template/file staleness.
396                 $since = time() - wfTimestamp( TS_UNIX, $parserOutput->getTimestamp() );
397                 $ttl = min( $parserOutput->getCacheExpiry() - $since, self::MAX_CACHE_TTL );
398
399                 // Avoid extremely stale user signature timestamps (T84843)
400                 if ( $parserOutput->getFlag( 'user-signature' ) ) {
401                         $ttl = min( $ttl, self::MAX_SIGNATURE_TTL );
402                 }
403
404                 if ( $ttl <= 0 ) {
405                         return [ null, 0, 'no_ttl' ];
406                 }
407
408                 // Only store what is actually needed
409                 $stashInfo = (object)[
410                         'pstContent' => $pstContent,
411                         'output'     => $parserOutput,
412                         'timestamp'  => $timestamp,
413                         'edits'      => $user->getEditCount()
414                 ];
415
416                 return [ $stashInfo, $ttl, 'ok' ];
417         }
418
419         public function getAllowedParams() {
420                 return [
421                         'title' => [
422                                 ApiBase::PARAM_TYPE => 'string',
423                                 ApiBase::PARAM_REQUIRED => true
424                         ],
425                         'section' => [
426                                 ApiBase::PARAM_TYPE => 'string',
427                         ],
428                         'sectiontitle' => [
429                                 ApiBase::PARAM_TYPE => 'string'
430                         ],
431                         'text' => [
432                                 ApiBase::PARAM_TYPE => 'text',
433                                 ApiBase::PARAM_DFLT => null
434                         ],
435                         'stashedtexthash' => [
436                                 ApiBase::PARAM_TYPE => 'string',
437                                 ApiBase::PARAM_DFLT => null
438                         ],
439                         'summary' => [
440                                 ApiBase::PARAM_TYPE => 'string',
441                         ],
442                         'contentmodel' => [
443                                 ApiBase::PARAM_TYPE => ContentHandler::getContentModels(),
444                                 ApiBase::PARAM_REQUIRED => true
445                         ],
446                         'contentformat' => [
447                                 ApiBase::PARAM_TYPE => ContentHandler::getAllContentFormats(),
448                                 ApiBase::PARAM_REQUIRED => true
449                         ],
450                         'baserevid' => [
451                                 ApiBase::PARAM_TYPE => 'integer',
452                                 ApiBase::PARAM_REQUIRED => true
453                         ]
454                 ];
455         }
456
457         public function needsToken() {
458                 return 'csrf';
459         }
460
461         public function mustBePosted() {
462                 return true;
463         }
464
465         public function isWriteMode() {
466                 return true;
467         }
468
469         public function isInternal() {
470                 return true;
471         }
472 }