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