Merge "Add suppressredirect right to the createeditmovepage grant"
[lhc/web/wiklou.git] / includes / api / ApiEditPage.php
1 <?php
2 /**
3 * Copyright © 2007 Iker Labarga "<Firstname><Lastname>@gmail.com"
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23 use MediaWiki\Storage\RevisionRecord;
24
25 /**
26 * A module that allows for editing and creating pages.
27 *
28 * Currently, this wraps around the EditPage class in an ugly way,
29 * EditPage.php should be rewritten to provide a cleaner interface,
30 * see T20654 if you're inspired to fix this.
31 *
32 * @ingroup API
33 */
34 class ApiEditPage extends ApiBase {
35 public function execute() {
36 $this->useTransactionalTimeLimit();
37
38 $user = $this->getUser();
39 $params = $this->extractRequestParams();
40
41 $this->requireAtLeastOneParameter( $params, 'text', 'appendtext', 'prependtext', 'undo' );
42
43 $pageObj = $this->getTitleOrPageId( $params );
44 $titleObj = $pageObj->getTitle();
45 $apiResult = $this->getResult();
46
47 if ( $params['redirect'] ) {
48 if ( $params['prependtext'] === null && $params['appendtext'] === null
49 && $params['section'] !== 'new'
50 ) {
51 $this->dieWithError( 'apierror-redirect-appendonly' );
52 }
53 if ( $titleObj->isRedirect() ) {
54 $oldTitle = $titleObj;
55
56 $titles = Revision::newFromTitle( $oldTitle, false, Revision::READ_LATEST )
57 ->getContent( RevisionRecord::FOR_THIS_USER, $user )
58 ->getRedirectChain();
59 // array_shift( $titles );
60
61 $redirValues = [];
62
63 /** @var Title $newTitle */
64 foreach ( $titles as $id => $newTitle ) {
65 if ( !isset( $titles[$id - 1] ) ) {
66 $titles[$id - 1] = $oldTitle;
67 }
68
69 $redirValues[] = [
70 'from' => $titles[$id - 1]->getPrefixedText(),
71 'to' => $newTitle->getPrefixedText()
72 ];
73
74 $titleObj = $newTitle;
75 }
76
77 ApiResult::setIndexedTagName( $redirValues, 'r' );
78 $apiResult->addValue( null, 'redirects', $redirValues );
79
80 // Since the page changed, update $pageObj
81 $pageObj = WikiPage::factory( $titleObj );
82 }
83 }
84
85 if ( !isset( $params['contentmodel'] ) || $params['contentmodel'] == '' ) {
86 $contentHandler = $pageObj->getContentHandler();
87 } else {
88 $contentHandler = ContentHandler::getForModelID( $params['contentmodel'] );
89 }
90 $contentModel = $contentHandler->getModelID();
91
92 $name = $titleObj->getPrefixedDBkey();
93 $model = $contentHandler->getModelID();
94
95 // always allow undo via api, T230702
96 if ( !( $params['undo'] > 0 ) && $contentHandler->supportsDirectApiEditing() === false ) {
97 $this->dieWithError( [ 'apierror-no-direct-editing', $model, $name ] );
98 }
99
100 if ( !isset( $params['contentformat'] ) || $params['contentformat'] == '' ) {
101 $contentFormat = $contentHandler->getDefaultFormat();
102 } else {
103 $contentFormat = $params['contentformat'];
104 }
105
106 if ( !$contentHandler->isSupportedFormat( $contentFormat ) ) {
107 $this->dieWithError( [ 'apierror-badformat', $contentFormat, $model, $name ] );
108 }
109
110 if ( $params['createonly'] && $titleObj->exists() ) {
111 $this->dieWithError( 'apierror-articleexists' );
112 }
113 if ( $params['nocreate'] && !$titleObj->exists() ) {
114 $this->dieWithError( 'apierror-missingtitle' );
115 }
116
117 // Now let's check whether we're even allowed to do this
118 $this->checkTitleUserPermissions(
119 $titleObj,
120 $titleObj->exists() ? 'edit' : [ 'edit', 'create' ],
121 [ 'autoblock' => true ]
122 );
123
124 $toMD5 = $params['text'];
125 if ( !is_null( $params['appendtext'] ) || !is_null( $params['prependtext'] ) ) {
126 $content = $pageObj->getContent();
127
128 if ( !$content ) {
129 if ( $titleObj->getNamespace() == NS_MEDIAWIKI ) {
130 # If this is a MediaWiki:x message, then load the messages
131 # and return the message value for x.
132 $text = $titleObj->getDefaultMessageText();
133 if ( $text === false ) {
134 $text = '';
135 }
136
137 try {
138 $content = ContentHandler::makeContent( $text, $titleObj );
139 } catch ( MWContentSerializationException $ex ) {
140 $this->dieWithException( $ex, [
141 'wrap' => ApiMessage::create( 'apierror-contentserializationexception', 'parseerror' )
142 ] );
143 return;
144 }
145 } else {
146 # Otherwise, make a new empty content.
147 $content = $contentHandler->makeEmptyContent();
148 }
149 }
150
151 // @todo Add support for appending/prepending to the Content interface
152
153 if ( !( $content instanceof TextContent ) ) {
154 $modelName = $contentHandler->getModelID();
155 $this->dieWithError( [ 'apierror-appendnotsupported', $modelName ] );
156 }
157
158 if ( !is_null( $params['section'] ) ) {
159 if ( !$contentHandler->supportsSections() ) {
160 $modelName = $contentHandler->getModelID();
161 $this->dieWithError( [ 'apierror-sectionsnotsupported', $modelName ] );
162 }
163
164 if ( $params['section'] == 'new' ) {
165 // DWIM if they're trying to prepend/append to a new section.
166 $content = null;
167 } else {
168 // Process the content for section edits
169 $section = $params['section'];
170 $content = $content->getSection( $section );
171
172 if ( !$content ) {
173 $this->dieWithError( [ 'apierror-nosuchsection', wfEscapeWikiText( $section ) ] );
174 }
175 }
176 }
177
178 if ( !$content ) {
179 $text = '';
180 } else {
181 $text = $content->serialize( $contentFormat );
182 }
183
184 $params['text'] = $params['prependtext'] . $text . $params['appendtext'];
185 $toMD5 = $params['prependtext'] . $params['appendtext'];
186 }
187
188 if ( $params['undo'] > 0 ) {
189 if ( $params['undoafter'] > 0 ) {
190 if ( $params['undo'] < $params['undoafter'] ) {
191 list( $params['undo'], $params['undoafter'] ) =
192 [ $params['undoafter'], $params['undo'] ];
193 }
194 $undoafterRev = Revision::newFromId( $params['undoafter'] );
195 }
196 $undoRev = Revision::newFromId( $params['undo'] );
197 if ( is_null( $undoRev ) || $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
198 $this->dieWithError( [ 'apierror-nosuchrevid', $params['undo'] ] );
199 }
200
201 if ( $params['undoafter'] == 0 ) {
202 $undoafterRev = $undoRev->getPrevious();
203 }
204 if ( is_null( $undoafterRev ) || $undoafterRev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
205 $this->dieWithError( [ 'apierror-nosuchrevid', $params['undoafter'] ] );
206 }
207
208 if ( $undoRev->getPage() != $pageObj->getId() ) {
209 $this->dieWithError( [ 'apierror-revwrongpage', $undoRev->getId(),
210 $titleObj->getPrefixedText() ] );
211 }
212 if ( $undoafterRev->getPage() != $pageObj->getId() ) {
213 $this->dieWithError( [ 'apierror-revwrongpage', $undoafterRev->getId(),
214 $titleObj->getPrefixedText() ] );
215 }
216
217 $newContent = $contentHandler->getUndoContent(
218 $pageObj->getRevision(),
219 $undoRev,
220 $undoafterRev
221 );
222
223 if ( !$newContent ) {
224 $this->dieWithError( 'undo-failure', 'undofailure' );
225 }
226 if ( empty( $params['contentmodel'] )
227 && empty( $params['contentformat'] )
228 ) {
229 // If we are reverting content model, the new content model
230 // might not support the current serialization format, in
231 // which case go back to the old serialization format,
232 // but only if the user hasn't specified a format/model
233 // parameter.
234 if ( !$newContent->isSupportedFormat( $contentFormat ) ) {
235 $contentFormat = $undoafterRev->getContentFormat();
236 }
237 // Override content model with model of undid revision.
238 $contentModel = $newContent->getModel();
239 }
240 $params['text'] = $newContent->serialize( $contentFormat );
241 // If no summary was given and we only undid one rev,
242 // use an autosummary
243 if ( is_null( $params['summary'] ) &&
244 $titleObj->getNextRevisionID( $undoafterRev->getId() ) == $params['undo']
245 ) {
246 $params['summary'] = wfMessage( 'undo-summary' )
247 ->params( $params['undo'], $undoRev->getUserText() )->inContentLanguage()->text();
248 }
249 }
250
251 // See if the MD5 hash checks out
252 if ( !is_null( $params['md5'] ) && md5( $toMD5 ) !== $params['md5'] ) {
253 $this->dieWithError( 'apierror-badmd5' );
254 }
255
256 // EditPage wants to parse its stuff from a WebRequest
257 // That interface kind of sucks, but it's workable
258 $requestArray = [
259 'wpTextbox1' => $params['text'],
260 'format' => $contentFormat,
261 'model' => $contentModel,
262 'wpEditToken' => $params['token'],
263 'wpIgnoreBlankSummary' => true,
264 'wpIgnoreBlankArticle' => true,
265 'wpIgnoreSelfRedirect' => true,
266 'bot' => $params['bot'],
267 'wpUnicodeCheck' => EditPage::UNICODE_CHECK,
268 ];
269
270 if ( !is_null( $params['summary'] ) ) {
271 $requestArray['wpSummary'] = $params['summary'];
272 }
273
274 if ( !is_null( $params['sectiontitle'] ) ) {
275 $requestArray['wpSectionTitle'] = $params['sectiontitle'];
276 }
277
278 // TODO: Pass along information from 'undoafter' as well
279 if ( $params['undo'] > 0 ) {
280 $requestArray['wpUndidRevision'] = $params['undo'];
281 }
282
283 // Watch out for basetimestamp == '' or '0'
284 // It gets treated as NOW, almost certainly causing an edit conflict
285 if ( $params['basetimestamp'] !== null && (bool)$this->getMain()->getVal( 'basetimestamp' ) ) {
286 $requestArray['wpEdittime'] = $params['basetimestamp'];
287 } else {
288 $requestArray['wpEdittime'] = $pageObj->getTimestamp();
289 }
290
291 if ( $params['starttimestamp'] !== null ) {
292 $requestArray['wpStarttime'] = $params['starttimestamp'];
293 } else {
294 $requestArray['wpStarttime'] = wfTimestampNow(); // Fake wpStartime
295 }
296
297 if ( $params['minor'] || ( !$params['notminor'] && $user->getOption( 'minordefault' ) ) ) {
298 $requestArray['wpMinoredit'] = '';
299 }
300
301 if ( $params['recreate'] ) {
302 $requestArray['wpRecreate'] = '';
303 }
304
305 if ( !is_null( $params['section'] ) ) {
306 $section = $params['section'];
307 if ( !preg_match( '/^((T-)?\d+|new)$/', $section ) ) {
308 $this->dieWithError( 'apierror-invalidsection' );
309 }
310 $content = $pageObj->getContent();
311 if ( $section !== '0' && $section != 'new'
312 && ( !$content || !$content->getSection( $section ) )
313 ) {
314 $this->dieWithError( [ 'apierror-nosuchsection', $section ] );
315 }
316 $requestArray['wpSection'] = $params['section'];
317 } else {
318 $requestArray['wpSection'] = '';
319 }
320
321 $watch = $this->getWatchlistValue( $params['watchlist'], $titleObj );
322
323 // Deprecated parameters
324 if ( $params['watch'] ) {
325 $watch = true;
326 } elseif ( $params['unwatch'] ) {
327 $watch = false;
328 }
329
330 if ( $watch ) {
331 $requestArray['wpWatchthis'] = '';
332 }
333
334 // Apply change tags
335 if ( $params['tags'] ) {
336 $tagStatus = ChangeTags::canAddTagsAccompanyingChange( $params['tags'], $user );
337 if ( $tagStatus->isOK() ) {
338 $requestArray['wpChangeTags'] = implode( ',', $params['tags'] );
339 } else {
340 $this->dieStatus( $tagStatus );
341 }
342 }
343
344 // Pass through anything else we might have been given, to support extensions
345 // This is kind of a hack but it's the best we can do to make extensions work
346 $requestArray += $this->getRequest()->getValues();
347
348 global $wgTitle, $wgRequest;
349
350 $req = new DerivativeRequest( $this->getRequest(), $requestArray, true );
351
352 // Some functions depend on $wgTitle == $ep->mTitle
353 // TODO: Make them not or check if they still do
354 $wgTitle = $titleObj;
355
356 $articleContext = new RequestContext;
357 $articleContext->setRequest( $req );
358 $articleContext->setWikiPage( $pageObj );
359 $articleContext->setUser( $this->getUser() );
360
361 /** @var Article $articleObject */
362 $articleObject = Article::newFromWikiPage( $pageObj, $articleContext );
363
364 $ep = new EditPage( $articleObject );
365
366 $ep->setApiEditOverride( true );
367 $ep->setContextTitle( $titleObj );
368 $ep->importFormData( $req );
369 $content = $ep->textbox1;
370
371 // Do the actual save
372 $oldRevId = $articleObject->getRevIdFetched();
373 $result = null;
374 // Fake $wgRequest for some hooks inside EditPage
375 // @todo FIXME: This interface SUCKS
376 $oldRequest = $wgRequest;
377 $wgRequest = $req;
378
379 $status = $ep->attemptSave( $result );
380 $wgRequest = $oldRequest;
381
382 switch ( $status->value ) {
383 case EditPage::AS_HOOK_ERROR:
384 case EditPage::AS_HOOK_ERROR_EXPECTED:
385 if ( isset( $status->apiHookResult ) ) {
386 $r = $status->apiHookResult;
387 $r['result'] = 'Failure';
388 $apiResult->addValue( null, $this->getModuleName(), $r );
389 return;
390 }
391 if ( !$status->getErrors() ) {
392 // This appears to be unreachable right now, because all
393 // code paths will set an error. Could change, though.
394 $status->fatal( 'hookaborted' ); //@codeCoverageIgnore
395 }
396 $this->dieStatus( $status );
397
398 // These two cases will normally have been caught earlier, and will
399 // only occur if something blocks the user between the earlier
400 // check and the check in EditPage (presumably a hook). It's not
401 // obvious that this is even possible.
402 // @codeCoverageIgnoreStart
403 case EditPage::AS_BLOCKED_PAGE_FOR_USER:
404 $this->dieBlocked( $user->getBlock() );
405
406 case EditPage::AS_READ_ONLY_PAGE:
407 $this->dieReadOnly();
408 // @codeCoverageIgnoreEnd
409
410 case EditPage::AS_SUCCESS_NEW_ARTICLE:
411 $r['new'] = true;
412 // fall-through
413
414 case EditPage::AS_SUCCESS_UPDATE:
415 $r['result'] = 'Success';
416 $r['pageid'] = (int)$titleObj->getArticleID();
417 $r['title'] = $titleObj->getPrefixedText();
418 $r['contentmodel'] = $articleObject->getContentModel();
419 $newRevId = $articleObject->getLatest();
420 if ( $newRevId == $oldRevId ) {
421 $r['nochange'] = true;
422 } else {
423 $r['oldrevid'] = (int)$oldRevId;
424 $r['newrevid'] = (int)$newRevId;
425 $r['newtimestamp'] = wfTimestamp( TS_ISO_8601,
426 $pageObj->getTimestamp() );
427 }
428 break;
429
430 default:
431 if ( !$status->getErrors() ) {
432 // EditPage sometimes only sets the status code without setting
433 // any actual error messages. Supply defaults for those cases.
434 switch ( $status->value ) {
435 // Currently needed
436 case EditPage::AS_IMAGE_REDIRECT_ANON:
437 $status->fatal( 'apierror-noimageredirect-anon' );
438 break;
439 case EditPage::AS_IMAGE_REDIRECT_LOGGED:
440 $status->fatal( 'apierror-noimageredirect' );
441 break;
442 case EditPage::AS_CONTENT_TOO_BIG:
443 case EditPage::AS_MAX_ARTICLE_SIZE_EXCEEDED:
444 $status->fatal( 'apierror-contenttoobig', $this->getConfig()->get( 'MaxArticleSize' ) );
445 break;
446 case EditPage::AS_READ_ONLY_PAGE_ANON:
447 $status->fatal( 'apierror-noedit-anon' );
448 break;
449 case EditPage::AS_NO_CHANGE_CONTENT_MODEL:
450 $status->fatal( 'apierror-cantchangecontentmodel' );
451 break;
452 case EditPage::AS_ARTICLE_WAS_DELETED:
453 $status->fatal( 'apierror-pagedeleted' );
454 break;
455 case EditPage::AS_CONFLICT_DETECTED:
456 $status->fatal( 'editconflict' );
457 break;
458
459 // Currently shouldn't be needed, but here in case
460 // hooks use them without setting appropriate
461 // errors on the status.
462 // @codeCoverageIgnoreStart
463 case EditPage::AS_SPAM_ERROR:
464 $status->fatal( 'apierror-spamdetected', $result['spam'] );
465 break;
466 case EditPage::AS_READ_ONLY_PAGE_LOGGED:
467 $status->fatal( 'apierror-noedit' );
468 break;
469 case EditPage::AS_RATE_LIMITED:
470 $status->fatal( 'apierror-ratelimited' );
471 break;
472 case EditPage::AS_NO_CREATE_PERMISSION:
473 $status->fatal( 'nocreate-loggedin' );
474 break;
475 case EditPage::AS_BLANK_ARTICLE:
476 $status->fatal( 'apierror-emptypage' );
477 break;
478 case EditPage::AS_TEXTBOX_EMPTY:
479 $status->fatal( 'apierror-emptynewsection' );
480 break;
481 case EditPage::AS_SUMMARY_NEEDED:
482 $status->fatal( 'apierror-summaryrequired' );
483 break;
484 default:
485 wfWarn( __METHOD__ . ": Unknown EditPage code {$status->value} with no message" );
486 $status->fatal( 'apierror-unknownerror-editpage', $status->value );
487 break;
488 // @codeCoverageIgnoreEnd
489 }
490 }
491 $this->dieStatus( $status );
492 }
493 $apiResult->addValue( null, $this->getModuleName(), $r );
494 }
495
496 public function mustBePosted() {
497 return true;
498 }
499
500 public function isWriteMode() {
501 return true;
502 }
503
504 public function getAllowedParams() {
505 return [
506 'title' => [
507 ApiBase::PARAM_TYPE => 'string',
508 ],
509 'pageid' => [
510 ApiBase::PARAM_TYPE => 'integer',
511 ],
512 'section' => null,
513 'sectiontitle' => [
514 ApiBase::PARAM_TYPE => 'string',
515 ],
516 'text' => [
517 ApiBase::PARAM_TYPE => 'text',
518 ],
519 'summary' => null,
520 'tags' => [
521 ApiBase::PARAM_TYPE => 'tags',
522 ApiBase::PARAM_ISMULTI => true,
523 ],
524 'minor' => false,
525 'notminor' => false,
526 'bot' => false,
527 'basetimestamp' => [
528 ApiBase::PARAM_TYPE => 'timestamp',
529 ],
530 'starttimestamp' => [
531 ApiBase::PARAM_TYPE => 'timestamp',
532 ],
533 'recreate' => false,
534 'createonly' => false,
535 'nocreate' => false,
536 'watch' => [
537 ApiBase::PARAM_DFLT => false,
538 ApiBase::PARAM_DEPRECATED => true,
539 ],
540 'unwatch' => [
541 ApiBase::PARAM_DFLT => false,
542 ApiBase::PARAM_DEPRECATED => true,
543 ],
544 'watchlist' => [
545 ApiBase::PARAM_DFLT => 'preferences',
546 ApiBase::PARAM_TYPE => [
547 'watch',
548 'unwatch',
549 'preferences',
550 'nochange'
551 ],
552 ],
553 'md5' => null,
554 'prependtext' => [
555 ApiBase::PARAM_TYPE => 'text',
556 ],
557 'appendtext' => [
558 ApiBase::PARAM_TYPE => 'text',
559 ],
560 'undo' => [
561 ApiBase::PARAM_TYPE => 'integer',
562 ApiBase::PARAM_MIN => 0,
563 ApiBase::PARAM_RANGE_ENFORCE => true,
564 ],
565 'undoafter' => [
566 ApiBase::PARAM_TYPE => 'integer',
567 ApiBase::PARAM_MIN => 0,
568 ApiBase::PARAM_RANGE_ENFORCE => true,
569 ],
570 'redirect' => [
571 ApiBase::PARAM_TYPE => 'boolean',
572 ApiBase::PARAM_DFLT => false,
573 ],
574 'contentformat' => [
575 ApiBase::PARAM_TYPE => ContentHandler::getAllContentFormats(),
576 ],
577 'contentmodel' => [
578 ApiBase::PARAM_TYPE => ContentHandler::getContentModels(),
579 ],
580 'token' => [
581 // Standard definition automatically inserted
582 ApiBase::PARAM_HELP_MSG_APPEND => [ 'apihelp-edit-param-token' ],
583 ],
584 ];
585 }
586
587 public function needsToken() {
588 return 'csrf';
589 }
590
591 protected function getExamplesMessages() {
592 return [
593 'action=edit&title=Test&summary=test%20summary&' .
594 'text=article%20content&basetimestamp=2007-08-24T12:34:54Z&token=123ABC'
595 => 'apihelp-edit-example-edit',
596 'action=edit&title=Test&summary=NOTOC&minor=&' .
597 'prependtext=__NOTOC__%0A&basetimestamp=2007-08-24T12:34:54Z&token=123ABC'
598 => 'apihelp-edit-example-prepend',
599 'action=edit&title=Test&undo=13585&undoafter=13579&' .
600 'basetimestamp=2007-08-24T12:34:54Z&token=123ABC'
601 => 'apihelp-edit-example-undo',
602 ];
603 }
604
605 public function getHelpUrls() {
606 return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Edit';
607 }
608 }