Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.92% covered (success)
96.92%
126 / 130
88.89% covered (warning)
88.89%
8 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiStashEdit
97.67% covered (success)
97.67%
126 / 129
88.89% covered (warning)
88.89%
8 / 9
27
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
81 / 81
100.00% covered (success)
100.00%
1 / 1
18
 getUserForPreview
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
2.50
 getAllowedParams
100.00% covered (success)
100.00%
36 / 36
100.00% covered (success)
100.00%
1 / 1
1
 needsToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mustBePosted
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isWriteMode
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isInternal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getHelpUrls
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Api;
8
9use Exception;
10use MediaWiki\Content\IContentHandlerFactory;
11use MediaWiki\Page\WikiPageFactory;
12use MediaWiki\Revision\RevisionLookup;
13use MediaWiki\Revision\SlotRecord;
14use MediaWiki\Storage\PageEditStash;
15use MediaWiki\User\TempUser\TempUserCreator;
16use MediaWiki\User\UserFactory;
17use MediaWiki\User\UserIdentity;
18use Wikimedia\ParamValidator\ParamValidator;
19use Wikimedia\Stats\StatsFactory;
20
21/**
22 * Prepare an edit in shared cache so that it can be reused on edit
23 *
24 * This endpoint can be called via AJAX as the user focuses on the edit
25 * summary box. By the time of submission, the parse may have already
26 * finished, and can be immediately used on page save. Certain parser
27 * functions like {{REVISIONID}} or {{CURRENTTIME}} may cause the cache
28 * to not be used on edit. Template and files used are checked for changes
29 * since the output was generated. The cache TTL is also kept low.
30 *
31 * @ingroup API
32 * @since 1.25
33 */
34class ApiStashEdit extends ApiBase {
35
36    public function __construct(
37        ApiMain $main,
38        string $action,
39        private readonly IContentHandlerFactory $contentHandlerFactory,
40        private readonly PageEditStash $pageEditStash,
41        private readonly RevisionLookup $revisionLookup,
42        private readonly StatsFactory $statsFactory,
43        private readonly WikiPageFactory $wikiPageFactory,
44        private readonly TempUserCreator $tempUserCreator,
45        private readonly UserFactory $userFactory,
46    ) {
47        parent::__construct( $main, $action );
48    }
49
50    public function execute() {
51        $user = $this->getUser();
52        $params = $this->extractRequestParams();
53
54        if ( $user->isBot() ) {
55            $this->dieWithError( 'apierror-botsnotsupported' );
56        }
57
58        $page = $this->getTitleOrPageId( $params );
59        $title = $page->getTitle();
60        $this->getErrorFormatter()->setContextTitle( $title );
61
62        if ( !$this->contentHandlerFactory
63            ->getContentHandler( $params['contentmodel'] )
64            ->isSupportedFormat( $params['contentformat'] )
65        ) {
66            $this->dieWithError(
67                [ 'apierror-badformat-generic', $params['contentformat'], $params['contentmodel'] ],
68                'badmodelformat'
69            );
70        }
71
72        $this->requireOnlyOneParameter( $params, 'stashedtexthash', 'text' );
73
74        if ( $params['stashedtexthash'] !== null ) {
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            $text = $this->pageEditStash->fetchInputText( $textHash );
81            if ( !is_string( $text ) ) {
82                $this->dieWithError( 'apierror-stashedit-missingtext', 'missingtext' );
83            }
84        } else {
85            // 'text' was passed.  Trim and fix newlines so the key SHA1's
86            // match (see WebRequest::getText())
87            $text = rtrim( str_replace( "\r\n", "\n", $params['text'] ) );
88            $textHash = sha1( $text );
89        }
90
91        $textContent = $this->contentHandlerFactory
92            ->getContentHandler( $params['contentmodel'] )
93            ->unserializeContent( $text, $params['contentformat'] );
94
95        $page = $this->wikiPageFactory->newFromTitle( $title );
96        if ( $page->exists() ) {
97            // Page exists: get the merged content with the proposed change
98            $baseRev = $this->revisionLookup->getRevisionByPageId(
99                $page->getId(),
100                $params['baserevid']
101            );
102            if ( !$baseRev ) {
103                $this->dieWithError( [ 'apierror-nosuchrevid', $params['baserevid'] ] );
104            }
105            $currentRev = $page->getRevisionRecord();
106            if ( !$currentRev ) {
107                $this->dieWithError( [ 'apierror-missingrev-pageid', $page->getId() ], 'missingrev' );
108            }
109            // Merge in the new version of the section to get the proposed version
110            $editContent = $page->replaceSectionAtRev(
111                $params['section'],
112                $textContent,
113                $params['sectiontitle'],
114                $baseRev->getId()
115            );
116            if ( !$editContent ) {
117                $this->dieWithError( 'apierror-sectionreplacefailed', 'replacefailed' );
118            }
119            if ( $currentRev->getId() == $baseRev->getId() ) {
120                // Base revision was still the latest; nothing to merge
121                $content = $editContent;
122            } else {
123                // Merge the edit into the current version
124                $baseContent = $baseRev->getContent( SlotRecord::MAIN );
125                $currentContent = $currentRev->getContent( SlotRecord::MAIN );
126                if ( !$baseContent || !$currentContent ) {
127                    $this->dieWithError( [ 'apierror-missingcontent-pageid', $page->getId() ], 'missingrev' );
128                }
129
130                $baseModel = $baseContent->getModel();
131                $currentModel = $currentContent->getModel();
132
133                // T255700: Put this in try-block because if the models of these three Contents
134                // happen to not be identical, the ContentHandler may throw exception here.
135                try {
136                    $content = $this->contentHandlerFactory
137                        ->getContentHandler( $baseModel )
138                        ->merge3( $baseContent, $editContent, $currentContent );
139                } catch ( Exception $e ) {
140                    $this->dieWithException( $e, [
141                        'wrap' => ApiMessage::create(
142                            [ 'apierror-contentmodel-mismatch', $currentModel, $baseModel ]
143                        )
144                    ] );
145                }
146
147            }
148        } else {
149            // New pages: use the user-provided content model
150            $content = $textContent;
151        }
152
153        if ( !$content ) { // merge3() failed
154            $this->getResult()->addValue( null,
155                $this->getModuleName(), [ 'status' => 'editconflict' ] );
156            return;
157        }
158
159        if ( !$user->authorizeWrite( 'stashedit', $title ) ) {
160            $status = 'ratelimited';
161        } else {
162            $user = $this->getUserForPreview();
163            $updater = $page->newPageUpdater( $user );
164            $status = $this->pageEditStash->parseAndCache( $updater, $content, $user, $params['summary'] );
165            $this->pageEditStash->stashInputText( $text, $textHash );
166        }
167
168        $this->statsFactory->getCounter( 'editstash_cache_stores_total' )
169            ->setLabel( 'status', $status )
170            ->increment();
171
172        $ret = [ 'status' => $status ];
173        // If we were rate-limited, we still return the pre-existing valid hash if one was passed
174        if ( $status !== 'ratelimited' || $params['stashedtexthash'] !== null ) {
175            $ret['texthash'] = $textHash;
176        }
177
178        $this->getResult()->addValue( null, $this->getModuleName(), $ret );
179    }
180
181    private function getUserForPreview(): UserIdentity {
182        $user = $this->getUser();
183        if ( $this->tempUserCreator->shouldAutoCreate( $user, 'edit' ) ) {
184            return $this->userFactory->newUnsavedTempUser(
185                $this->tempUserCreator->getStashedName( $this->getRequest()->getSession() )
186            );
187        }
188        return $user;
189    }
190
191    /** @inheritDoc */
192    public function getAllowedParams() {
193        return [
194            'title' => [
195                ParamValidator::PARAM_TYPE => 'string',
196                ParamValidator::PARAM_REQUIRED => true
197            ],
198            'section' => [
199                ParamValidator::PARAM_TYPE => 'string',
200            ],
201            'sectiontitle' => [
202                ParamValidator::PARAM_TYPE => 'string'
203            ],
204            'text' => [
205                ParamValidator::PARAM_TYPE => 'text',
206                ParamValidator::PARAM_DEFAULT => null
207            ],
208            'stashedtexthash' => [
209                ParamValidator::PARAM_TYPE => 'string',
210                ParamValidator::PARAM_DEFAULT => null
211            ],
212            'summary' => [
213                ParamValidator::PARAM_TYPE => 'string',
214                ParamValidator::PARAM_DEFAULT => ''
215            ],
216            'contentmodel' => [
217                ParamValidator::PARAM_TYPE => $this->contentHandlerFactory->getContentModels(),
218                ParamValidator::PARAM_REQUIRED => true
219            ],
220            'contentformat' => [
221                ParamValidator::PARAM_TYPE => $this->contentHandlerFactory->getAllContentFormats(),
222                ParamValidator::PARAM_REQUIRED => true
223            ],
224            'baserevid' => [
225                ParamValidator::PARAM_TYPE => 'integer',
226                ParamValidator::PARAM_REQUIRED => true
227            ]
228        ];
229    }
230
231    /** @inheritDoc */
232    public function needsToken() {
233        return 'csrf';
234    }
235
236    /** @inheritDoc */
237    public function mustBePosted() {
238        return true;
239    }
240
241    /** @inheritDoc */
242    public function isWriteMode() {
243        return true;
244    }
245
246    /** @inheritDoc */
247    public function isInternal() {
248        return true;
249    }
250
251    /** @inheritDoc */
252    public function getHelpUrls() {
253        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Stashedit';
254    }
255}
256
257/** @deprecated class alias since 1.43 */
258class_alias( ApiStashEdit::class, 'ApiStashEdit' );