Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.47% covered (success)
93.47%
415 / 444
45.45% covered (danger)
45.45%
5 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiComparePages
93.47% covered (success)
93.47%
415 / 444
45.45% covered (danger)
45.45%
5 / 11
140.08
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 execute
99.09% covered (success)
99.09%
109 / 110
0.00% covered (danger)
0.00%
0 / 1
34
 getRevisionById
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 guessTitle
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
10
 guessModel
68.42% covered (warning)
68.42%
13 / 19
0.00% covered (danger)
0.00%
0 / 1
14.81
 getDiffRevision
89.78% covered (warning)
89.78%
123 / 137
0.00% covered (danger)
0.00%
0 / 1
50.46
 setVals
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
19
 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%
89 / 89
100.00% covered (success)
100.00%
1 / 1
5
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpUrls
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
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\CommentFormatter\CommentFormatter;
22use MediaWiki\Content\IContentHandlerFactory;
23use MediaWiki\Content\Transform\ContentTransformer;
24use MediaWiki\Revision\ArchivedRevisionLookup;
25use MediaWiki\Revision\MutableRevisionRecord;
26use MediaWiki\Revision\RevisionArchiveRecord;
27use MediaWiki\Revision\RevisionRecord;
28use MediaWiki\Revision\RevisionStore;
29use MediaWiki\Revision\SlotRecord;
30use MediaWiki\Revision\SlotRoleRegistry;
31use MediaWiki\Title\Title;
32use MediaWiki\User\TempUser\TempUserCreator;
33use MediaWiki\User\UserFactory;
34use Wikimedia\ParamValidator\ParamValidator;
35use Wikimedia\RequestTimeout\TimeoutException;
36
37/**
38 * @ingroup API
39 */
40class ApiComparePages extends ApiBase {
41
42    private RevisionStore $revisionStore;
43    private ArchivedRevisionLookup $archivedRevisionLookup;
44    private SlotRoleRegistry $slotRoleRegistry;
45
46    /** @var Title|null|false */
47    private $guessedTitle = false;
48    private $props;
49
50    private IContentHandlerFactory $contentHandlerFactory;
51    private ContentTransformer $contentTransformer;
52    private CommentFormatter $commentFormatter;
53    private TempUserCreator $tempUserCreator;
54    private UserFactory $userFactory;
55    private DifferenceEngine $differenceEngine;
56
57    /**
58     * @param ApiMain $mainModule
59     * @param string $moduleName
60     * @param RevisionStore $revisionStore
61     * @param ArchivedRevisionLookup $archivedRevisionLookup
62     * @param SlotRoleRegistry $slotRoleRegistry
63     * @param IContentHandlerFactory $contentHandlerFactory
64     * @param ContentTransformer $contentTransformer
65     * @param CommentFormatter $commentFormatter
66     * @param TempUserCreator $tempUserCreator
67     * @param UserFactory $userFactory
68     */
69    public function __construct(
70        ApiMain $mainModule,
71        $moduleName,
72        RevisionStore $revisionStore,
73        ArchivedRevisionLookup $archivedRevisionLookup,
74        SlotRoleRegistry $slotRoleRegistry,
75        IContentHandlerFactory $contentHandlerFactory,
76        ContentTransformer $contentTransformer,
77        CommentFormatter $commentFormatter,
78        TempUserCreator $tempUserCreator,
79        UserFactory $userFactory
80    ) {
81        parent::__construct( $mainModule, $moduleName );
82        $this->revisionStore = $revisionStore;
83        $this->archivedRevisionLookup = $archivedRevisionLookup;
84        $this->slotRoleRegistry = $slotRoleRegistry;
85        $this->contentHandlerFactory = $contentHandlerFactory;
86        $this->contentTransformer = $contentTransformer;
87        $this->commentFormatter = $commentFormatter;
88        $this->tempUserCreator = $tempUserCreator;
89        $this->userFactory = $userFactory;
90        $this->differenceEngine = new DifferenceEngine;
91    }
92
93    public function execute() {
94        $params = $this->extractRequestParams();
95
96        // Parameter validation
97        $this->requireAtLeastOneParameter(
98            $params, 'fromtitle', 'fromid', 'fromrev', 'fromtext', 'fromslots'
99        );
100        $this->requireAtLeastOneParameter(
101            $params, 'totitle', 'toid', 'torev', 'totext', 'torelative', 'toslots'
102        );
103
104        $this->props = array_fill_keys( $params['prop'], true );
105
106        // Cache responses publicly by default. This may be overridden later.
107        $this->getMain()->setCacheMode( 'public' );
108
109        // Get the 'from' RevisionRecord
110        [ $fromRev, $fromRelRev, $fromValsRev ] = $this->getDiffRevision( 'from', $params );
111
112        // Get the 'to' RevisionRecord
113        if ( $params['torelative'] !== null ) {
114            if ( !$fromRelRev ) {
115                $this->dieWithError( 'apierror-compare-relative-to-nothing' );
116            }
117            if ( $params['torelative'] !== 'cur' && $fromRelRev instanceof RevisionArchiveRecord ) {
118                // RevisionStore's getPreviousRevision/getNextRevision blow up
119                // when passed an RevisionArchiveRecord for a deleted page
120                $this->dieWithError( [ 'apierror-compare-relative-to-deleted', $params['torelative'] ] );
121            }
122            switch ( $params['torelative'] ) {
123                case 'prev':
124                    // Swap 'from' and 'to'
125                    [ $toRev, $toRelRev, $toValsRev ] = [ $fromRev, $fromRelRev, $fromValsRev ];
126                    $fromRev = $this->revisionStore->getPreviousRevision( $toRelRev );
127                    $fromRelRev = $fromRev;
128                    $fromValsRev = $fromRev;
129                    if ( !$fromRev ) {
130                        $title = Title::newFromLinkTarget( $toRelRev->getPageAsLinkTarget() );
131                        $this->addWarning( [
132                            'apiwarn-compare-no-prev',
133                            wfEscapeWikiText( $title->getPrefixedText() ),
134                            $toRelRev->getId()
135                        ] );
136
137                        // (T203433) Create an empty dummy revision as the "previous".
138                        // The main slot has to exist, the rest will be handled by DifferenceEngine.
139                        $fromRev = new MutableRevisionRecord(
140                            $title ?: $toRev->getPage()
141                        );
142                        $fromRev->setContent(
143                            SlotRecord::MAIN,
144                            $toRelRev->getContent( SlotRecord::MAIN, RevisionRecord::RAW )
145                                ->getContentHandler()
146                                ->makeEmptyContent()
147                        );
148                    }
149                    break;
150
151                case 'next':
152                    $toRev = $this->revisionStore->getNextRevision( $fromRelRev );
153                    $toRelRev = $toRev;
154                    $toValsRev = $toRev;
155                    if ( !$toRev ) {
156                        $title = Title::newFromLinkTarget( $fromRelRev->getPageAsLinkTarget() );
157                        $this->addWarning( [
158                            'apiwarn-compare-no-next',
159                            wfEscapeWikiText( $title->getPrefixedText() ),
160                            $fromRelRev->getId()
161                        ] );
162
163                        // (T203433) The web UI treats "next" as "cur" in this case.
164                        // Avoid repeating metadata by making a MutableRevisionRecord with no changes.
165                        $toRev = MutableRevisionRecord::newFromParentRevision( $fromRelRev );
166                    }
167                    break;
168
169                case 'cur':
170                    $title = $fromRelRev->getPageAsLinkTarget();
171                    $toRev = $this->revisionStore->getRevisionByTitle( $title );
172                    if ( !$toRev ) {
173                        $title = Title::newFromLinkTarget( $title );
174                        $this->dieWithError(
175                            [ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ],
176                            'nosuchrevid'
177                        );
178                    }
179                    $toRelRev = $toRev;
180                    $toValsRev = $toRev;
181                    break;
182            }
183        } else {
184            [ $toRev, $toRelRev, $toValsRev ] = $this->getDiffRevision( 'to', $params );
185        }
186
187        // Handle missing from or to revisions (should never happen)
188        // @codeCoverageIgnoreStart
189        // @phan-suppress-next-line PhanPossiblyUndeclaredVariable T240141
190        if ( !$fromRev || !$toRev ) {
191            $this->dieWithError( 'apierror-baddiff' );
192        }
193        // @codeCoverageIgnoreEnd
194
195        // Handle revdel
196        if ( !$fromRev->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
197            $this->dieWithError( [ 'apierror-missingcontent-revid', $fromRev->getId() ], 'missingcontent' );
198        }
199        if ( !$toRev->userCan( RevisionRecord::DELETED_TEXT, $this->getAuthority() ) ) {
200            $this->dieWithError( [ 'apierror-missingcontent-revid', $toRev->getId() ], 'missingcontent' );
201        }
202
203        // Get the diff
204        $context = new DerivativeContext( $this->getContext() );
205        if ( $fromRelRev && $fromRelRev->getPageAsLinkTarget() ) {
206            $context->setTitle( Title::newFromLinkTarget( $fromRelRev->getPageAsLinkTarget() ) );
207        // @phan-suppress-next-line PhanPossiblyUndeclaredVariable T240141
208        } elseif ( $toRelRev && $toRelRev->getPageAsLinkTarget() ) {
209            $context->setTitle( Title::newFromLinkTarget( $toRelRev->getPageAsLinkTarget() ) );
210        } else {
211            $guessedTitle = $this->guessTitle();
212            if ( $guessedTitle ) {
213                $context->setTitle( $guessedTitle );
214            }
215        }
216        $this->differenceEngine->setContext( $context );
217        $this->differenceEngine->setSlotDiffOptions( [ 'diff-type' => $params['difftype'] ] );
218        $this->differenceEngine->setRevisions( $fromRev, $toRev );
219        if ( $params['slots'] === null ) {
220            $difftext = $this->differenceEngine->getDiffBody();
221            if ( $difftext === false ) {
222                $this->dieWithError( 'apierror-baddiff' );
223            }
224        } else {
225            $difftext = [];
226            foreach ( $params['slots'] as $role ) {
227                $difftext[$role] = $this->differenceEngine->getDiffBodyForRole( $role );
228            }
229        }
230        foreach ( $this->differenceEngine->getRevisionLoadErrors() as $msg ) {
231            $this->addWarning( $msg );
232        }
233
234        // Fill in the response
235        $vals = [];
236        $this->setVals( $vals, 'from', $fromValsRev );
237        // @phan-suppress-next-line PhanPossiblyUndeclaredVariable T240141
238        $this->setVals( $vals, 'to', $toValsRev );
239
240        if ( isset( $this->props['rel'] ) ) {
241            if ( !$fromRev instanceof MutableRevisionRecord ) {
242                $rev = $this->revisionStore->getPreviousRevision( $fromRev );
243                if ( $rev ) {
244                    $vals['prev'] = $rev->getId();
245                }
246            }
247            if ( !$toRev instanceof MutableRevisionRecord ) {
248                $rev = $this->revisionStore->getNextRevision( $toRev );
249                if ( $rev ) {
250                    $vals['next'] = $rev->getId();
251                }
252            }
253        }
254
255        if ( isset( $this->props['diffsize'] ) ) {
256            $vals['diffsize'] = 0;
257            foreach ( (array)$difftext as $text ) {
258                $vals['diffsize'] += strlen( $text );
259            }
260        }
261        if ( isset( $this->props['diff'] ) ) {
262            if ( is_array( $difftext ) ) {
263                ApiResult::setArrayType( $difftext, 'kvp', 'diff' );
264                $vals['bodies'] = $difftext;
265            } else {
266                ApiResult::setContentValue( $vals, 'body', $difftext );
267            }
268        }
269
270        // Diffs can be really big and there's little point in having
271        // ApiResult truncate it to an empty response since the diff is the
272        // whole reason this module exists. So pass NO_SIZE_CHECK here.
273        $this->getResult()->addValue( null, $this->getModuleName(), $vals, ApiResult::NO_SIZE_CHECK );
274    }
275
276    /**
277     * Load a revision by ID
278     *
279     * Falls back to checking the archive table if appropriate.
280     *
281     * @param int $id
282     * @return RevisionRecord|null
283     */
284    private function getRevisionById( $id ) {
285        $rev = $this->revisionStore->getRevisionById( $id );
286        if ( !$rev && $this->getAuthority()->isAllowedAny( 'deletedtext', 'undelete' ) ) {
287            // Try the 'archive' table
288            $rev = $this->archivedRevisionLookup->getArchivedRevisionRecord( null, $id );
289        }
290        return $rev;
291    }
292
293    /**
294     * Guess an appropriate default Title for this request
295     *
296     * @return Title|null
297     */
298    private function guessTitle() {
299        if ( $this->guessedTitle !== false ) {
300            return $this->guessedTitle;
301        }
302
303        $this->guessedTitle = null;
304        $params = $this->extractRequestParams();
305
306        foreach ( [ 'from', 'to' ] as $prefix ) {
307            if ( $params["{$prefix}rev"] !== null ) {
308                $rev = $this->getRevisionById( $params["{$prefix}rev"] );
309                if ( $rev ) {
310                    $this->guessedTitle = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() );
311                    break;
312                }
313            }
314
315            if ( $params["{$prefix}title"] !== null ) {
316                $title = Title::newFromText( $params["{$prefix}title"] );
317                if ( $title && !$title->isExternal() ) {
318                    $this->guessedTitle = $title;
319                    break;
320                }
321            }
322
323            if ( $params["{$prefix}id"] !== null ) {
324                $title = Title::newFromID( $params["{$prefix}id"] );
325                if ( $title ) {
326                    $this->guessedTitle = $title;
327                    break;
328                }
329            }
330        }
331
332        return $this->guessedTitle;
333    }
334
335    /**
336     * Guess an appropriate default content model for this request
337     * @param string $role Slot for which to guess the model
338     * @return string|null Guessed content model
339     */
340    private function guessModel( $role ) {
341        $params = $this->extractRequestParams();
342
343        foreach ( [ 'from', 'to' ] as $prefix ) {
344            if ( $params["{$prefix}rev"] !== null ) {
345                $rev = $this->getRevisionById( $params["{$prefix}rev"] );
346                if ( $rev && $rev->hasSlot( $role ) ) {
347                    return $rev->getSlot( $role, RevisionRecord::RAW )->getModel();
348                }
349            }
350        }
351
352        $guessedTitle = $this->guessTitle();
353        if ( $guessedTitle ) {
354            return $this->slotRoleRegistry->getRoleHandler( $role )->getDefaultModel( $guessedTitle );
355        }
356
357        if ( isset( $params["fromcontentmodel-$role"] ) ) {
358            return $params["fromcontentmodel-$role"];
359        }
360        if ( isset( $params["tocontentmodel-$role"] ) ) {
361            return $params["tocontentmodel-$role"];
362        }
363
364        if ( $role === SlotRecord::MAIN ) {
365            if ( isset( $params['fromcontentmodel'] ) ) {
366                return $params['fromcontentmodel'];
367            }
368            if ( isset( $params['tocontentmodel'] ) ) {
369                return $params['tocontentmodel'];
370            }
371        }
372
373        return null;
374    }
375
376    /**
377     * Get the RevisionRecord for one side of the diff
378     *
379     * This uses the appropriate set of parameters to determine what content
380     * should be diffed.
381     *
382     * Returns three values:
383     * - A RevisionRecord holding the content
384     * - The revision specified, if any, even if content was supplied
385     * - The revision to pass to setVals(), if any
386     *
387     * @param string $prefix 'from' or 'to'
388     * @param array $params
389     * @return array [ RevisionRecord|null, RevisionRecord|null, RevisionRecord|null ]
390     */
391    private function getDiffRevision( $prefix, array $params ) {
392        // Back compat params
393        $this->requireMaxOneParameter( $params, "{$prefix}text", "{$prefix}slots" );
394        $this->requireMaxOneParameter( $params, "{$prefix}section", "{$prefix}slots" );
395        if ( $params["{$prefix}text"] !== null ) {
396            $params["{$prefix}slots"] = [ SlotRecord::MAIN ];
397            $params["{$prefix}text-main"] = $params["{$prefix}text"];
398            $params["{$prefix}section-main"] = null;
399            $params["{$prefix}contentmodel-main"] = $params["{$prefix}contentmodel"];
400            $params["{$prefix}contentformat-main"] = $params["{$prefix}contentformat"];
401        }
402
403        $title = null;
404        $rev = null;
405        $suppliedContent = $params["{$prefix}slots"] !== null;
406
407        // Get the revision and title, if applicable
408        $revId = null;
409        if ( $params["{$prefix}rev"] !== null ) {
410            $revId = $params["{$prefix}rev"];
411        } elseif ( $params["{$prefix}title"] !== null || $params["{$prefix}id"] !== null ) {
412            if ( $params["{$prefix}title"] !== null ) {
413                $title = Title::newFromText( $params["{$prefix}title"] );
414                if ( !$title || $title->isExternal() ) {
415                    $this->dieWithError(
416                        [ 'apierror-invalidtitle', wfEscapeWikiText( $params["{$prefix}title"] ) ]
417                    );
418                }
419            } else {
420                $title = Title::newFromID( $params["{$prefix}id"] );
421                if ( !$title ) {
422                    $this->dieWithError( [ 'apierror-nosuchpageid', $params["{$prefix}id"] ] );
423                }
424            }
425            $revId = $title->getLatestRevID();
426            if ( !$revId ) {
427                $revId = null;
428                // Only die here if we're not using supplied text
429                if ( !$suppliedContent ) {
430                    if ( $title->exists() ) {
431                        $this->dieWithError(
432                            [ 'apierror-missingrev-title', wfEscapeWikiText( $title->getPrefixedText() ) ],
433                            'nosuchrevid'
434                        );
435                    } else {
436                        $this->dieWithError(
437                            [ 'apierror-missingtitle-byname', wfEscapeWikiText( $title->getPrefixedText() ) ],
438                            'missingtitle'
439                        );
440                    }
441                }
442            }
443        }
444        if ( $revId !== null ) {
445            $rev = $this->getRevisionById( $revId );
446            if ( !$rev ) {
447                $this->dieWithError( [ 'apierror-nosuchrevid', $revId ] );
448            }
449            $title = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() );
450
451            // If we don't have supplied content, return here. Otherwise,
452            // continue on below with the supplied content.
453            if ( !$suppliedContent ) {
454                $newRev = $rev;
455
456                // Deprecated 'fromsection'/'tosection'
457                if ( isset( $params["{$prefix}section"] ) ) {
458                    $section = $params["{$prefix}section"];
459                    // @phan-suppress-next-line PhanTypeMismatchArgumentNullable T240141
460                    $newRev = MutableRevisionRecord::newFromParentRevision( $rev );
461                    $content = $rev->getContent( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER,
462                        $this->getAuthority() );
463                    if ( !$content ) {
464                        $this->dieWithError(
465                            [ 'apierror-missingcontent-revid-role', $rev->getId(), SlotRecord::MAIN ], 'missingcontent'
466                        );
467                    }
468                    $content = $content->getSection( $section );
469                    if ( !$content ) {
470                        $this->dieWithError(
471                            [ "apierror-compare-nosuch{$prefix}section", wfEscapeWikiText( $section ) ],
472                            "nosuch{$prefix}section"
473                        );
474                    }
475                    // @phan-suppress-next-line PhanTypeMismatchArgumentNullable T240141
476                    $newRev->setContent( SlotRecord::MAIN, $content );
477                }
478
479                return [ $newRev, $rev, $rev ];
480            }
481        }
482
483        // Override $content based on supplied text
484        if ( !$title ) {
485            $title = $this->guessTitle();
486        }
487        if ( $rev ) {
488            $newRev = MutableRevisionRecord::newFromParentRevision( $rev );
489        } else {
490            $newRev = new MutableRevisionRecord( $title ?: Title::newMainPage() );
491        }
492        foreach ( $params["{$prefix}slots"] as $role ) {
493            $text = $params["{$prefix}text-{$role}"];
494            if ( $text === null ) {
495                // The SlotRecord::MAIN role can't be deleted
496                if ( $role === SlotRecord::MAIN ) {
497                    $this->dieWithError( [ 'apierror-compare-maintextrequired', $prefix ] );
498                }
499
500                // These parameters make no sense without text. Reject them to avoid
501                // confusion.
502                foreach ( [ 'section', 'contentmodel', 'contentformat' ] as $param ) {
503                    if ( isset( $params["{$prefix}{$param}-{$role}"] ) ) {
504                        $this->dieWithError( [
505                            'apierror-compare-notext',
506                            wfEscapeWikiText( "{$prefix}{$param}-{$role}" ),
507                            wfEscapeWikiText( "{$prefix}text-{$role}" ),
508                        ] );
509                    }
510                }
511
512                $newRev->removeSlot( $role );
513                continue;
514            }
515
516            $model = $params["{$prefix}contentmodel-{$role}"];
517            $format = $params["{$prefix}contentformat-{$role}"];
518
519            if ( !$model && $rev && $rev->hasSlot( $role ) ) {
520                $model = $rev->getSlot( $role, RevisionRecord::RAW )->getModel();
521            }
522            if ( !$model && $title && $role === SlotRecord::MAIN ) {
523                // @todo: Use SlotRoleRegistry and do this for all slots
524                $model = $title->getContentModel();
525            }
526            if ( !$model ) {
527                $model = $this->guessModel( $role );
528            }
529            if ( !$model ) {
530                $model = CONTENT_MODEL_WIKITEXT;
531                $this->addWarning( [ 'apiwarn-compare-nocontentmodel', $model ] );
532            }
533
534            try {
535                $content = $this->contentHandlerFactory
536                    ->getContentHandler( $model )
537                    ->unserializeContent( $text, $format );
538            } catch ( MWContentSerializationException $ex ) {
539                $this->dieWithException( $ex, [
540                    'wrap' => ApiMessage::create( 'apierror-contentserializationexception', 'parseerror' )
541                ] );
542            }
543
544            if ( $params["{$prefix}pst"] ) {
545                if ( !$title ) {
546                    $this->dieWithError( 'apierror-compare-no-title' );
547                }
548                $popts = ParserOptions::newFromContext( $this->getContext() );
549                $content = $this->contentTransformer->preSaveTransform(
550                    $content,
551                    // @phan-suppress-next-line PhanTypeMismatchArgumentNullable T240141
552                    $title,
553                    $this->getUserForPreview(),
554                    $popts
555                );
556            }
557
558            $section = $params["{$prefix}section-{$role}"];
559            if ( $section !== null && $section !== '' ) {
560                if ( !$rev ) {
561                    $this->dieWithError( "apierror-compare-no{$prefix}revision" );
562                }
563                $oldContent = $rev->getContent( $role, RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
564                if ( !$oldContent ) {
565                    $this->dieWithError(
566                        [ 'apierror-missingcontent-revid-role', $rev->getId(), wfEscapeWikiText( $role ) ],
567                        'missingcontent'
568                    );
569                }
570                if ( !$oldContent->getContentHandler()->supportsSections() ) {
571                    $this->dieWithError( [ 'apierror-sectionsnotsupported', $content->getModel() ] );
572                }
573                try {
574                    // @phan-suppress-next-line PhanTypeMismatchArgumentNullable T240141
575                    $content = $oldContent->replaceSection( $section, $content, '' );
576                } catch ( TimeoutException $e ) {
577                    throw $e;
578                } catch ( Exception $ex ) {
579                    // Probably a content model mismatch.
580                    $content = null;
581                }
582                if ( !$content ) {
583                    $this->dieWithError( [ 'apierror-sectionreplacefailed' ] );
584                }
585            }
586
587            // Deprecated 'fromsection'/'tosection'
588            if ( $role === SlotRecord::MAIN && isset( $params["{$prefix}section"] ) ) {
589                $section = $params["{$prefix}section"];
590                $content = $content->getSection( $section );
591                if ( !$content ) {
592                    $this->dieWithError(
593                        [ "apierror-compare-nosuch{$prefix}section", wfEscapeWikiText( $section ) ],
594                        "nosuch{$prefix}section"
595                    );
596                }
597            }
598
599            // @phan-suppress-next-line PhanTypeMismatchArgumentNullable T240141
600            $newRev->setContent( $role, $content );
601        }
602        return [ $newRev, $rev, null ];
603    }
604
605    /**
606     * Set value fields from a RevisionRecord object
607     *
608     * @param array &$vals Result array to set data into
609     * @param string $prefix 'from' or 'to'
610     * @param RevisionRecord|null $rev
611     */
612    private function setVals( &$vals, $prefix, $rev ) {
613        if ( $rev ) {
614            $title = Title::newFromLinkTarget( $rev->getPageAsLinkTarget() );
615            if ( isset( $this->props['ids'] ) ) {
616                $vals["{$prefix}id"] = $title->getArticleID();
617                $vals["{$prefix}revid"] = $rev->getId();
618            }
619            if ( isset( $this->props['title'] ) ) {
620                ApiQueryBase::addTitleInfo( $vals, $title, $prefix );
621            }
622            if ( isset( $this->props['size'] ) ) {
623                $vals["{$prefix}size"] = $rev->getSize();
624            }
625            if ( isset( $this->props['timestamp'] ) ) {
626                $revTimestamp = $rev->getTimestamp();
627                if ( $revTimestamp ) {
628                    $vals["{$prefix}timestamp"] = wfTimestamp( TS_ISO_8601, $revTimestamp );
629                }
630            }
631
632            $anyHidden = false;
633            if ( $rev->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
634                $vals["{$prefix}texthidden"] = true;
635                $anyHidden = true;
636            }
637
638            if ( $rev->isDeleted( RevisionRecord::DELETED_USER ) ) {
639                $vals["{$prefix}userhidden"] = true;
640                $anyHidden = true;
641            }
642            if ( isset( $this->props['user'] ) ) {
643                $user = $rev->getUser( RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
644                if ( $user ) {
645                    $vals["{$prefix}user"] = $user->getName();
646                    $vals["{$prefix}userid"] = $user->getId();
647                }
648            }
649
650            if ( $rev->isDeleted( RevisionRecord::DELETED_COMMENT ) ) {
651                $vals["{$prefix}commenthidden"] = true;
652                $anyHidden = true;
653            }
654            if ( isset( $this->props['comment'] ) || isset( $this->props['parsedcomment'] ) ) {
655                $comment = $rev->getComment( RevisionRecord::FOR_THIS_USER, $this->getAuthority() );
656                if ( $comment !== null ) {
657                    if ( isset( $this->props['comment'] ) ) {
658                        $vals["{$prefix}comment"] = $comment->text;
659                    }
660                    $vals["{$prefix}parsedcomment"] = $this->commentFormatter->format(
661                        $comment->text, $title
662                    );
663                }
664            }
665
666            if ( $anyHidden ) {
667                $this->getMain()->setCacheMode( 'private' );
668                if ( $rev->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ) {
669                    $vals["{$prefix}suppressed"] = true;
670                }
671            }
672
673            if ( $rev instanceof RevisionArchiveRecord ) {
674                $this->getMain()->setCacheMode( 'private' );
675                $vals["{$prefix}archive"] = true;
676            }
677        }
678    }
679
680    private function getUserForPreview() {
681        $user = $this->getUser();
682        if ( $this->tempUserCreator->shouldAutoCreate( $user, 'edit' ) ) {
683            return $this->userFactory->newUnsavedTempUser(
684                $this->tempUserCreator->getStashedName( $this->getRequest()->getSession() )
685            );
686        }
687        return $user;
688    }
689
690    public function getAllowedParams() {
691        $slotRoles = $this->slotRoleRegistry->getKnownRoles();
692        sort( $slotRoles, SORT_STRING );
693
694        // Parameters for the 'from' and 'to' content
695        $fromToParams = [
696            'title' => null,
697            'id' => [
698                ParamValidator::PARAM_TYPE => 'integer'
699            ],
700            'rev' => [
701                ParamValidator::PARAM_TYPE => 'integer'
702            ],
703
704            'slots' => [
705                ParamValidator::PARAM_TYPE => $slotRoles,
706                ParamValidator::PARAM_ISMULTI => true,
707            ],
708            'text-{slot}' => [
709                ApiBase::PARAM_TEMPLATE_VARS => [ 'slot' => 'slots' ], // fixed below
710                ParamValidator::PARAM_TYPE => 'text',
711            ],
712            'section-{slot}' => [
713                ApiBase::PARAM_TEMPLATE_VARS => [ 'slot' => 'slots' ], // fixed below
714                ParamValidator::PARAM_TYPE => 'string',
715            ],
716            'contentformat-{slot}' => [
717                ApiBase::PARAM_TEMPLATE_VARS => [ 'slot' => 'slots' ], // fixed below
718                ParamValidator::PARAM_TYPE => $this->contentHandlerFactory->getAllContentFormats(),
719            ],
720            'contentmodel-{slot}' => [
721                ApiBase::PARAM_TEMPLATE_VARS => [ 'slot' => 'slots' ], // fixed below
722                ParamValidator::PARAM_TYPE => $this->contentHandlerFactory->getContentModels(),
723            ],
724            'pst' => false,
725
726            'text' => [
727                ParamValidator::PARAM_TYPE => 'text',
728                ParamValidator::PARAM_DEPRECATED => true,
729            ],
730            'contentformat' => [
731                ParamValidator::PARAM_TYPE => $this->contentHandlerFactory->getAllContentFormats(),
732                ParamValidator::PARAM_DEPRECATED => true,
733            ],
734            'contentmodel' => [
735                ParamValidator::PARAM_TYPE => $this->contentHandlerFactory->getContentModels(),
736                ParamValidator::PARAM_DEPRECATED => true,
737            ],
738            'section' => [
739                ParamValidator::PARAM_DEFAULT => null,
740                ParamValidator::PARAM_DEPRECATED => true,
741            ],
742        ];
743
744        $ret = [];
745        foreach ( $fromToParams as $k => $v ) {
746            if ( isset( $v[ApiBase::PARAM_TEMPLATE_VARS]['slot'] ) ) {
747                $v[ApiBase::PARAM_TEMPLATE_VARS]['slot'] = 'fromslots';
748            }
749            $ret["from$k"] = $v;
750        }
751        foreach ( $fromToParams as $k => $v ) {
752            if ( isset( $v[ApiBase::PARAM_TEMPLATE_VARS]['slot'] ) ) {
753                $v[ApiBase::PARAM_TEMPLATE_VARS]['slot'] = 'toslots';
754            }
755            $ret["to$k"] = $v;
756        }
757
758        $ret = wfArrayInsertAfter(
759            $ret,
760            [ 'torelative' => [ ParamValidator::PARAM_TYPE => [ 'prev', 'next', 'cur' ], ] ],
761            'torev'
762        );
763
764        $ret['prop'] = [
765            ParamValidator::PARAM_DEFAULT => 'diff|ids|title',
766            ParamValidator::PARAM_TYPE => [
767                'diff',
768                'diffsize',
769                'rel',
770                'ids',
771                'title',
772                'user',
773                'comment',
774                'parsedcomment',
775                'size',
776                'timestamp',
777            ],
778            ParamValidator::PARAM_ISMULTI => true,
779            ApiBase::PARAM_HELP_MSG_PER_VALUE => [],
780        ];
781
782        $ret['slots'] = [
783            ParamValidator::PARAM_TYPE => $slotRoles,
784            ParamValidator::PARAM_ISMULTI => true,
785            ParamValidator::PARAM_ALL => true,
786        ];
787
788        $ret['difftype'] = [
789            ParamValidator::PARAM_TYPE => $this->differenceEngine->getSupportedFormats(),
790            ParamValidator::PARAM_DEFAULT => 'table',
791        ];
792
793        return $ret;
794    }
795
796    protected function getExamplesMessages() {
797        return [
798            'action=compare&fromrev=1&torev=2'
799                => 'apihelp-compare-example-1',
800        ];
801    }
802
803    public function getHelpUrls() {
804        return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Compare';
805    }
806}