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