Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.26% covered (warning)
86.26%
860 / 997
43.48% covered (danger)
43.48%
30 / 69
CRAP
0.00% covered (danger)
0.00%
0 / 1
DifferenceEngine
86.26% covered (warning)
86.26%
860 / 997
43.48% covered (danger)
43.48%
30 / 69
567.19
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 getSlotDiffRenderers
90.00% covered (success)
90.00%
18 / 20
0.00% covered (danger)
0.00%
0 / 1
9.08
 markAsSlotDiffRenderer
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSlotContents
96.15% covered (success)
96.15%
25 / 26
0.00% covered (danger)
0.00%
0 / 1
6
 loadSingleSlot
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
 addRevisionLoadError
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getRevisionLoadErrors
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasNewRevisionLoadError
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
 getTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 setReducedLineNumbers
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDiffLang
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDefaultLanguage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 wasCacheHit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getOldid
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getNewid
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getOldRevision
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getNewRevision
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deletedLink
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
7.23
 deletedIdMarker
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 showMissingRevision
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
6.02
 hasDeletedRevision
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 getPermissionErrors
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 hasSuppressedRevision
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 getUserEditCount
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 getUserRoles
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 getUserMetaData
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 isUserAllowedToSeeRevisions
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 shouldBeHiddenFromUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 showDiffPage
93.36% covered (success)
93.36%
197 / 211
0.00% covered (danger)
0.00%
0 / 1
43.54
 showTablePrefixes
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 markPatrolledLink
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 getMarkPatrolledLinkInfo
95.45% covered (success)
95.45%
21 / 22
0.00% covered (danger)
0.00%
0 / 1
8
 revisionDeleteLink
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 renderNewRevision
76.47% covered (warning)
76.47%
39 / 51
0.00% covered (danger)
0.00%
0 / 1
12.58
 showDiff
70.00% covered (warning)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
3.24
 showDiffStyle
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 getDiff
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 incrementStats
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 getDiffBody
84.75% covered (warning)
84.75%
50 / 59
0.00% covered (danger)
0.00%
0 / 1
26.04
 getDiffBodyForRole
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
30
 getSlotHeader
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getSlotError
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getDiffBodyCacheKeyParams
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 getExtraCacheKeys
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 setSlotDiffOptions
66.67% covered (warning)
66.67%
6 / 9
0.00% covered (danger)
0.00%
0 / 1
5.93
 setExtraQueryParams
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 generateContentDiffBody
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 generateTextDiffBody
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
2.09
 getEngine
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
2.15
 debug
22.22% covered (danger)
22.22%
2 / 9
0.00% covered (danger)
0.00%
0 / 1
7.23
 getDebugString
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 localiseDiff
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 localiseLineNumbers
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getMultiNotice
95.92% covered (success)
95.92%
47 / 49
0.00% covered (danger)
0.00%
0 / 1
21
 intermediateEditsMsg
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
3.85
 userCanEdit
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getRevisionHeader
100.00% covered (success)
100.00%
47 / 47
100.00% covered (success)
100.00%
1 / 1
7
 addHeader
100.00% covered (success)
100.00%
41 / 41
100.00% covered (success)
100.00%
1 / 1
8
 setContent
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 setRevisions
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
5
 setTextLanguage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 mapDiffPrevNext
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
7
 loadRevisionIds
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 loadRevisionData
81.82% covered (warning)
81.82%
45 / 55
0.00% covered (danger)
0.00%
0 / 1
19.95
 loadText
87.50% covered (warning)
87.50%
21 / 24
0.00% covered (danger)
0.00%
0 / 1
9.16
 loadNewText
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 getTextDiffer
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 getSupportedFormats
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTextDiffFormat
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * User interface for the difference engine.
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 * @ingroup DifferenceEngine
22 */
23
24use MediaWiki\CommentFormatter\CommentFormatter;
25use MediaWiki\Content\Content;
26use MediaWiki\Content\IContentHandlerFactory;
27use MediaWiki\Context\ContextSource;
28use MediaWiki\Context\IContextSource;
29use MediaWiki\Debug\DeprecationHelper;
30use MediaWiki\Diff\TextDiffer\ManifoldTextDiffer;
31use MediaWiki\HookContainer\HookRunner;
32use MediaWiki\Html\Html;
33use MediaWiki\Language\Language;
34use MediaWiki\Linker\Linker;
35use MediaWiki\Linker\LinkRenderer;
36use MediaWiki\MainConfigNames;
37use MediaWiki\MediaWikiServices;
38use MediaWiki\Message\Message;
39use MediaWiki\Page\ParserOutputAccess;
40use MediaWiki\Page\WikiPageFactory;
41use MediaWiki\Permissions\Authority;
42use MediaWiki\Permissions\PermissionStatus;
43use MediaWiki\Revision\ArchivedRevisionLookup;
44use MediaWiki\Revision\BadRevisionException;
45use MediaWiki\Revision\RevisionRecord;
46use MediaWiki\Revision\RevisionStore;
47use MediaWiki\Revision\SlotRecord;
48use MediaWiki\SpecialPage\SpecialPage;
49use MediaWiki\Storage\NameTableAccessException;
50use MediaWiki\Title\Title;
51use MediaWiki\User\Options\UserOptionsLookup;
52use MediaWiki\User\UserEditTracker;
53use MediaWiki\User\UserGroupManager;
54use MediaWiki\User\UserGroupMembership;
55use MediaWiki\User\UserIdentity;
56use MediaWiki\User\UserIdentityUtils;
57use Wikimedia\Rdbms\IConnectionProvider;
58
59/**
60 * DifferenceEngine is responsible for rendering the difference between two revisions as HTML.
61 * This includes interpreting URL parameters, retrieving revision data, checking access permissions,
62 * selecting and invoking the diff generator class for the individual slots, doing post-processing
63 * on the generated diff, adding the rest of the HTML (such as headers) and writing the whole thing
64 * to OutputPage.
65 *
66 * DifferenceEngine can be subclassed by extensions, by customizing
67 * ContentHandler::createDifferenceEngine; the content handler will be selected based on the
68 * content model of the main slot (of the new revision, when the two are different).
69 * That might change after PageTypeHandler gets introduced.
70 *
71 * In the past, the class was also used for slot-level diff generation, and extensions might still
72 * subclass it and add such functionality. When that is the case (specifically, when a
73 * ContentHandler returns a standard SlotDiffRenderer but a nonstandard DifferenceEngine)
74 * DifferenceEngineSlotDiffRenderer will be used to convert the old behavior into the new one.
75 *
76 * @ingroup DifferenceEngine
77 *
78 * @todo This class is huge and poorly defined. It should be split into a controller responsible
79 * for interpreting query parameters, retrieving data and checking permissions; and a HTML renderer.
80 */
81class DifferenceEngine extends ContextSource {
82
83    use DeprecationHelper;
84
85    /**
86     * Constant to indicate diff cache compatibility.
87     * Bump this when changing the diff formatting in a way that
88     * fixes important bugs or such to force cached diff views to
89     * clear.
90     */
91    private const DIFF_VERSION = '1.41';
92
93    /**
94     * Revision ID for the old revision. 0 for the revision previous to $mNewid, false
95     * if the diff does not have an old revision (e.g. 'oldid=<first revision of page>&diff=prev'),
96     * or the revision does not exist, null if the revision is unsaved.
97     * @var int|false|null
98     */
99    protected $mOldid;
100
101    /**
102     * Revision ID for the new revision. 0 for the last revision of the current page
103     * (as defined by the request context), false if the revision does not exist, null
104     * if it is unsaved, or an alias such as 'next'.
105     * @var int|string|false|null
106     */
107    protected $mNewid;
108
109    /**
110     * Old revision (left pane).
111     * Allowed to be an unsaved revision, unlikely that's ever needed though.
112     * False when the old revision does not exist; this can happen when using
113     * diff=prev on the first revision. Null when the revision should exist but
114     * doesn't (e.g. load failure); loadRevisionData() will return false in that
115     * case. Also null until lazy-loaded. Ignored completely when isContentOverridden
116     * is set.
117     * @var RevisionRecord|null|false
118     */
119    private $mOldRevisionRecord;
120
121    /**
122     * New revision (right pane).
123     * Note that this might be an unsaved revision (e.g. for edit preview).
124     * Null in case of load failure; diff methods will just return an error message in that case,
125     * and loadRevisionData() will return false. Also null until lazy-loaded. Ignored completely
126     * when isContentOverridden is set.
127     * @var RevisionRecord|null
128     */
129    private $mNewRevisionRecord;
130
131    /**
132     * Title of old revision or null if the old revision does not exist or does not belong to a page.
133     * @var Title|null
134     */
135    protected $mOldPage;
136
137    /**
138     * Title of new revision or null if the new revision does not exist or does not belong to a page.
139     * @var Title|null
140     */
141    protected $mNewPage;
142
143    /**
144     * Change tags of old revision or null if it does not exist / is not saved.
145     * @var string|false|null
146     */
147    private $mOldTags;
148
149    /**
150     * Change tags of new revision or null if it does not exist / is not saved.
151     * @var string|null
152     */
153    private $mNewTags;
154
155    /**
156     * @var Content|null
157     * @deprecated since 1.32, content slots are now handled by the corresponding SlotDiffRenderer.
158     *   This property is set to the content of the main slot, but not actually used for the main diff.
159     */
160    private $mOldContent;
161
162    /**
163     * @var Content|null
164     * @deprecated since 1.32, content slots are now handled by the corresponding SlotDiffRenderer.
165     *   This property is set to the content of the main slot, but not actually used for the main diff.
166     */
167    private $mNewContent;
168
169    /** @var Language */
170    protected $mDiffLang;
171
172    /** @var bool Have the revisions IDs been loaded */
173    private $mRevisionsIdsLoaded = false;
174
175    /** @var bool Have the revisions been loaded */
176    protected $mRevisionsLoaded = false;
177
178    /** @var int How many text blobs have been loaded, 0, 1 or 2? */
179    protected $mTextLoaded = 0;
180
181    /**
182     * Was the content overridden via setContent()?
183     * If the content was overridden, most internal state (e.g. mOldid or mOldRev) should be ignored
184     * and only mOldContent and mNewContent is reliable.
185     * (Note that setRevisions() does not set this flag as in that case all properties are
186     * overridden and remain consistent with each other, so no special handling is needed.)
187     * @var bool
188     */
189    protected $isContentOverridden = false;
190
191    /** @var bool Was the diff fetched from cache? */
192    protected $mCacheHit = false;
193
194    /** @var string|null Cache key if the diff was fetched from cache */
195    private $cacheHitKey = null;
196
197    /**
198     * Set this to true to add debug info to the HTML output.
199     * Warning: this may cause RSS readers to spuriously mark articles as "new"
200     * (T22601)
201     * @var bool
202     */
203    public $enableDebugComment = false;
204
205    /** @var bool If true, line X is not displayed when X is 1, for example
206     *    to increase readability and conserve space with many small diffs.
207     */
208    protected $mReducedLineNumbers = false;
209
210    /** @var string Link to action=markpatrolled */
211    protected $mMarkPatrolledLink = null;
212
213    /** @var bool Show rev_deleted content if allowed */
214    protected $unhide = false;
215
216    /** @var bool Refresh the diff cache */
217    protected $mRefreshCache = false;
218
219    /** @var SlotDiffRenderer[]|null DifferenceEngine classes for the slots, keyed by role name. */
220    protected $slotDiffRenderers = null;
221
222    /**
223     * Temporary hack for B/C while slot diff related methods of DifferenceEngine are being
224     * deprecated. When true, we are inside a DifferenceEngineSlotDiffRenderer and
225     * $slotDiffRenderers should not be used.
226     * @var bool
227     */
228    protected $isSlotDiffRenderer = false;
229
230    /**
231     * A set of options that will be passed to the SlotDiffRenderer upon creation
232     * @var array
233     */
234    private $slotDiffOptions = [];
235
236    /**
237     * Extra query parameters to be appended to diff page links
238     * @var array
239     */
240    private $extraQueryParams = [];
241
242    /** @var ManifoldTextDiffer|null */
243    private $textDiffer;
244
245    protected LinkRenderer $linkRenderer;
246    private IContentHandlerFactory $contentHandlerFactory;
247    private RevisionStore $revisionStore;
248    private ArchivedRevisionLookup $archivedRevisionLookup;
249    private HookRunner $hookRunner;
250    private WikiPageFactory $wikiPageFactory;
251    private UserOptionsLookup $userOptionsLookup;
252    private CommentFormatter $commentFormatter;
253    private IConnectionProvider $dbProvider;
254    private UserGroupManager $userGroupManager;
255    private UserEditTracker $userEditTracker;
256    private UserIdentityUtils $userIdentityUtils;
257
258    /** @var Message[] */
259    private $revisionLoadErrors = [];
260
261    /**
262     * @param IContextSource|null $context Context to use, anything else will be ignored
263     * @param int $old Old ID we want to show and diff with.
264     * @param string|int $new Either revision ID or 'prev' or 'next'. Default: 0.
265     * @param int $rcid Deprecated, no longer used!
266     * @param bool $refreshCache If set, refreshes the diff cache
267     * @param bool $unhide If set, allow viewing deleted revs
268     */
269    public function __construct( $context = null, $old = 0, $new = 0, $rcid = 0,
270        $refreshCache = false, $unhide = false
271    ) {
272        if ( $context instanceof IContextSource ) {
273            $this->setContext( $context );
274        }
275
276        wfDebug( "DifferenceEngine old '$old' new '$new' rcid '$rcid'" );
277
278        $this->mOldid = $old;
279        $this->mNewid = $new;
280        $this->mRefreshCache = $refreshCache;
281        $this->unhide = $unhide;
282
283        $services = MediaWikiServices::getInstance();
284        $this->linkRenderer = $services->getLinkRenderer();
285        $this->contentHandlerFactory = $services->getContentHandlerFactory();
286        $this->revisionStore = $services->getRevisionStore();
287        $this->archivedRevisionLookup = $services->getArchivedRevisionLookup();
288        $this->hookRunner = new HookRunner( $services->getHookContainer() );
289        $this->wikiPageFactory = $services->getWikiPageFactory();
290        $this->userOptionsLookup = $services->getUserOptionsLookup();
291        $this->commentFormatter = $services->getCommentFormatter();
292        $this->dbProvider = $services->getConnectionProvider();
293        $this->userGroupManager = $services->getUserGroupManager();
294        $this->userEditTracker = $services->getUserEditTracker();
295        $this->userIdentityUtils = $services->getUserIdentityUtils();
296    }
297
298    /**
299     * @return SlotDiffRenderer[] Diff renderers for each slot, keyed by role name.
300     *   Includes slots only present in one of the revisions. Does not include slots
301     *   for which content is identical in the two revisions.
302     */
303    protected function getSlotDiffRenderers() {
304        if ( $this->isSlotDiffRenderer ) {
305            throw new LogicException( __METHOD__ . ' called in slot diff renderer mode' );
306        }
307
308        if ( $this->slotDiffRenderers === null ) {
309            if ( !$this->loadRevisionData() ) {
310                return [];
311            }
312
313            $slotContents = $this->getSlotContents();
314            $this->slotDiffRenderers = [];
315            foreach ( $slotContents as $role => $contents ) {
316                if ( $contents['new'] && $contents['old']
317                    && $contents['new']->equals( $contents['old'] )
318                ) {
319                    // Do not produce a diff of identical content
320                    continue;
321                }
322                $handler = ( $contents['new'] ?: $contents['old'] )->getContentHandler();
323                $this->slotDiffRenderers[$role] = $handler->getSlotDiffRenderer(
324                    $this->getContext(),
325                    $this->slotDiffOptions + [
326                        'contentLanguage' => $this->getDiffLang()->getCode(),
327                        'textDiffer' => $this->getTextDiffer()
328                    ]
329                );
330            }
331        }
332
333        return $this->slotDiffRenderers;
334    }
335
336    /**
337     * Mark this DifferenceEngine as a slot renderer (as opposed to a page renderer).
338     * This is used in legacy mode when the DifferenceEngine is wrapped in a
339     * DifferenceEngineSlotDiffRenderer.
340     * @internal For use by DifferenceEngineSlotDiffRenderer only.
341     */
342    public function markAsSlotDiffRenderer() {
343        $this->isSlotDiffRenderer = true;
344    }
345
346    /**
347     * Get the old and new content objects for all slots.
348     * This method does not do any permission checks.
349     * @return (Content|null)[][] [ role => [ 'old' => Content|null, 'new' => Content|null ], ... ]
350     */
351    protected function getSlotContents() {
352        if ( $this->isContentOverridden ) {
353            return [
354                SlotRecord::MAIN => [ 'old' => $this->mOldContent, 'new' => $this->mNewContent ]
355            ];
356        } elseif ( !$this->loadRevisionData() ) {
357            return [];
358        }
359
360        $newSlots = $this->mNewRevisionRecord->getPrimarySlots()->getSlots();
361        $oldSlots = $this->mOldRevisionRecord ?
362            $this->mOldRevisionRecord->getPrimarySlots()->getSlots() :
363            [];
364        // The order here will determine the visual order of the diff. The current logic is
365        // slots of the new revision first in natural order, then deleted ones. This is ad hoc
366        // and should not be relied on - in the future we may want the ordering to depend
367        // on the page type.
368        $roles = array_keys( array_merge( $newSlots, $oldSlots ) );
369
370        $slots = [];
371        foreach ( $roles as $role ) {
372            $slots[$role] = [
373                'old' => $this->loadSingleSlot(
374                    $oldSlots[$role] ?? null,
375                    'old'
376                ),
377                'new' => $this->loadSingleSlot(
378                    $newSlots[$role] ?? null,
379                    'new'
380                )
381            ];
382        }
383        // move main slot to front
384        if ( isset( $slots[SlotRecord::MAIN] ) ) {
385            $slots = [ SlotRecord::MAIN => $slots[SlotRecord::MAIN] ] + $slots;
386        }
387        return $slots;
388    }
389
390    /**
391     * Load the content of a single slot record
392     *
393     * @param SlotRecord|null $slot
394     * @param string $which "new" or "old"
395     * @return Content|null
396     */
397    private function loadSingleSlot( ?SlotRecord $slot, string $which ) {
398        if ( !$slot ) {
399            return null;
400        }
401        try {
402            return $slot->getContent();
403        } catch ( BadRevisionException $e ) {
404            $this->addRevisionLoadError( $which );
405            return null;
406        }
407    }
408
409    /**
410     * Set a message to show as a notice at the top of the page
411     *
412     * @param string $which "new" or "old"
413     */
414    private function addRevisionLoadError( $which ) {
415        $this->revisionLoadErrors[] = $this->msg( $which === 'new'
416            ? 'difference-bad-new-revision' : 'difference-bad-old-revision'
417        );
418    }
419
420    /**
421     * If errors were encountered while loading the revision contents, this
422     * will return an array of Messages describing the errors.
423     *
424     * @return Message[]
425     */
426    public function getRevisionLoadErrors() {
427        return $this->revisionLoadErrors;
428    }
429
430    /**
431     * Determine whether there was an error loading the new revision
432     * @return bool
433     */
434    private function hasNewRevisionLoadError() {
435        foreach ( $this->revisionLoadErrors as $error ) {
436            if ( $error->getKey() === 'difference-bad-new-revision' ) {
437                return true;
438            }
439        }
440        return false;
441    }
442
443    /** @inheritDoc */
444    public function getTitle() {
445        // T202454 avoid errors when there is no title
446        return parent::getTitle() ?: Title::makeTitle( NS_SPECIAL, 'BadTitle/DifferenceEngine' );
447    }
448
449    /**
450     * Set reduced line numbers mode.
451     * When set, line X is not displayed when X is 1, for example to increase readability and
452     * conserve space with many small diffs.
453     * @param bool $value
454     */
455    public function setReducedLineNumbers( $value = true ) {
456        $this->mReducedLineNumbers = $value;
457    }
458
459    /**
460     * Get the language in which the diff text is written
461     *
462     * @return Language
463     */
464    public function getDiffLang() {
465        # Default language in which the diff text is written.
466        $this->mDiffLang ??= $this->getDefaultLanguage();
467        return $this->mDiffLang;
468    }
469
470    /**
471     * Get the language to use if none has been set by setTextLanguage().
472     * Wikibase overrides this to use the user language.
473     *
474     * @return Language
475     */
476    protected function getDefaultLanguage() {
477        return $this->getTitle()->getPageLanguage();
478    }
479
480    /**
481     * @return bool
482     */
483    public function wasCacheHit() {
484        return $this->mCacheHit;
485    }
486
487    /**
488     * Get the ID of old revision (left pane) of the diff. 0 for the revision
489     * previous to getNewid(), false if the old revision does not exist, null
490     * if it's unsaved.
491     * To get a real revision ID instead of 0, call loadRevisionData() first.
492     * @return int|false|null
493     */
494    public function getOldid() {
495        $this->loadRevisionIds();
496
497        return $this->mOldid;
498    }
499
500    /**
501     * Get the ID of new revision (right pane) of the diff. 0 for the current revision,
502     * false if the new revision does not exist, null if it's unsaved.
503     * To get a real revision ID instead of 0, call loadRevisionData() first.
504     * @return int|false|null
505     */
506    public function getNewid() {
507        $this->loadRevisionIds();
508
509        return $this->mNewid;
510    }
511
512    /**
513     * Get the left side of the diff.
514     * Could be null when the first revision of the page is diffed to 'prev' (or in the case of
515     * load failure).
516     * @return RevisionRecord|null
517     */
518    public function getOldRevision() {
519        return $this->mOldRevisionRecord ?: null;
520    }
521
522    /**
523     * Get the right side of the diff.
524     * Should not be null but can still happen in the case of load failure.
525     * @return RevisionRecord|null
526     */
527    public function getNewRevision() {
528        return $this->mNewRevisionRecord;
529    }
530
531    /**
532     * Look up a special:Undelete link to the given deleted revision id,
533     * as a workaround for being unable to load deleted diffs in currently.
534     *
535     * @param int $id Revision ID
536     *
537     * @return string|bool Link HTML or false
538     */
539    public function deletedLink( $id ) {
540        if ( $this->getAuthority()->isAllowed( 'deletedhistory' ) ) {
541            $revRecord = $this->archivedRevisionLookup->getArchivedRevisionRecord( null, $id );
542            if ( $revRecord ) {
543                $title = Title::newFromPageIdentity( $revRecord->getPage() );
544
545                return SpecialPage::getTitleFor( 'Undelete' )->getFullURL( [
546                    'target' => $title->getPrefixedText(),
547                    'timestamp' => $revRecord->getTimestamp()
548                ] );
549            }
550        }
551
552        return false;
553    }
554
555    /**
556     * Build a wikitext link toward a deleted revision, if viewable.
557     *
558     * @param int $id Revision ID
559     *
560     * @return string Wikitext fragment
561     */
562    public function deletedIdMarker( $id ) {
563        $link = $this->deletedLink( $id );
564        if ( $link ) {
565            return "[$link $id]";
566        } else {
567            return (string)$id;
568        }
569    }
570
571    private function showMissingRevision() {
572        $out = $this->getOutput();
573
574        $missing = [];
575        if ( $this->mOldid && ( !$this->mOldRevisionRecord || !$this->mOldContent ) ) {
576            $missing[] = $this->deletedIdMarker( $this->mOldid );
577        }
578        if ( !$this->mNewRevisionRecord || !$this->mNewContent ) {
579            $missing[] = $this->deletedIdMarker( $this->mNewid );
580        }
581
582        $out->setPageTitleMsg( $this->msg( 'errorpagetitle' ) );
583        $msg = $this->msg( 'difference-missing-revision' )
584            ->params( $this->getLanguage()->listToText( $missing ) )
585            ->numParams( count( $missing ) )
586            ->parseAsBlock();
587        $out->addHTML( $msg );
588    }
589
590    /**
591     * Checks whether one of the given Revisions was deleted
592     *
593     * @return bool
594     */
595    public function hasDeletedRevision() {
596        $this->loadRevisionData();
597        return (
598                $this->mNewRevisionRecord &&
599                $this->mNewRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
600            ) ||
601            (
602                $this->mOldRevisionRecord &&
603                $this->mOldRevisionRecord->isDeleted( RevisionRecord::DELETED_TEXT )
604            );
605    }
606
607    /**
608     * Get the permission errors associated with the revisions for the current diff.
609     *
610     * @param Authority $performer
611     * @return array[] Array of arrays of the arguments to wfMessage to explain permissions problems.
612     */
613    public function getPermissionErrors( Authority $performer ) {
614        $this->loadRevisionData();
615        $permStatus = PermissionStatus::newEmpty();
616        if ( $this->mNewPage ) {
617            $performer->authorizeRead( 'read', $this->mNewPage, $permStatus );
618        }
619        if ( $this->mOldPage ) {
620            $performer->authorizeRead( 'read', $this->mOldPage, $permStatus );
621        }
622        return $permStatus->toLegacyErrorArray();
623    }
624
625    /**
626     * Checks whether one of the given Revisions was suppressed
627     *
628     * @return bool
629     */
630    public function hasSuppressedRevision() {
631        return $this->hasDeletedRevision() && (
632            ( $this->mOldRevisionRecord &&
633                $this->mOldRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) ) ||
634            ( $this->mNewRevisionRecord &&
635                $this->mNewRevisionRecord->isDeleted( RevisionRecord::DELETED_RESTRICTED ) )
636        );
637    }
638
639    /**
640     * Renders user associated edit count
641     *
642     * @param UserIdentity $user
643     * @return string
644     */
645    private function getUserEditCount( $user ): string {
646        $editCount = $this->userEditTracker->getUserEditCount( $user );
647        if ( $editCount === null ) {
648            return '';
649        }
650
651        return Html::rawElement( 'div', [
652            'class' => 'mw-diff-usereditcount',
653        ],
654            $this->msg(
655                'diff-user-edits',
656                $this->getLanguage()->formatNum( $editCount )
657            )->parse()
658        );
659    }
660
661    /**
662     * Renders user roles
663     *
664     * @param UserIdentity $user
665     * @return string
666     */
667    private function getUserRoles( UserIdentity $user ) {
668        if ( !$this->userIdentityUtils->isNamed( $user ) ) {
669            return '';
670        }
671        $userGroups = $this->userGroupManager->getUserGroups( $user );
672        $userGroupLinks = [];
673        foreach ( $userGroups as $group ) {
674            $userGroupLinks[] = UserGroupMembership::getLinkHTML( $group, $this->getContext() );
675        }
676        return Html::rawElement( 'div', [
677            'class' => 'mw-diff-userroles',
678        ], $this->getLanguage()->commaList( $userGroupLinks ) );
679    }
680
681    /**
682     * Renders user associated meta data
683     *
684     * @param UserIdentity|null $user
685     * @return string
686     */
687    private function getUserMetaData( ?UserIdentity $user ) {
688        if ( !$user ) {
689            return '';
690        }
691        return Html::rawElement( 'div', [
692            'class' => 'mw-diff-usermetadata',
693        ], $this->getUserRoles( $user ) . $this->getUserEditCount( $user ) );
694    }
695
696    /**
697     * Checks whether the current user has permission for accessing the revisions of the diff.
698     * Note that this does not check whether the user has permission to view the page, it only
699     * checks revdelete permissions.
700     *
701     * It is the caller's responsibility to call
702     * $this->getUserPermissionErrors or similar checks.
703     *
704     * @param Authority $performer
705     * @return bool
706     */
707    public function isUserAllowedToSeeRevisions( Authority $performer ) {
708        $this->loadRevisionData();
709
710        if ( $this->mOldRevisionRecord && !$this->mOldRevisionRecord->userCan(
711            RevisionRecord::DELETED_TEXT,
712            $performer
713        ) ) {
714            return false;
715        }
716
717        // $this->mNewRev will only be falsy if a loading error occurred
718        // (in which case the user is allowed to see).
719        return !$this->mNewRevisionRecord || $this->mNewRevisionRecord->userCan(
720            RevisionRecord::DELETED_TEXT,
721            $performer
722        );
723    }
724
725    /**
726     * Checks whether the diff should be hidden from the current user
727     * This is based on whether the user is allowed to see it and has specifically asked to see it.
728     *
729     * @param Authority $performer
730     * @return bool
731     */
732    public function shouldBeHiddenFromUser( Authority $performer ) {
733        return $this->hasDeletedRevision() && ( !$this->unhide ||
734            !$this->isUserAllowedToSeeRevisions( $performer ) );
735    }
736
737    /**
738     * @param bool $diffOnly
739     */
740    public function showDiffPage( $diffOnly = false ) {
741        # Allow frames except in certain special cases
742        $out = $this->getOutput();
743        $out->getMetadata()->setPreventClickjacking( false );
744        $out->setRobotPolicy( 'noindex,nofollow' );
745
746        // Allow extensions to add any extra output here
747        $this->hookRunner->onDifferenceEngineShowDiffPage( $out );
748
749        if ( !$this->loadRevisionData() ) {
750            if ( $this->hookRunner->onDifferenceEngineShowDiffPageMaybeShowMissingRevision( $this ) ) {
751                $this->showMissingRevision();
752            }
753            return;
754        }
755
756        $user = $this->getUser();
757        $permErrors = $this->getPermissionErrors( $this->getAuthority() );
758        if ( $permErrors ) {
759            throw new PermissionsError( 'read', $permErrors );
760        }
761
762        $rollback = '';
763
764        $query = $this->extraQueryParams;
765        # Carry over 'diffonly' param via navigation links
766        if ( $diffOnly != MediaWikiServices::getInstance()
767            ->getUserOptionsLookup()->getBoolOption( $user, 'diffonly' )
768        ) {
769            $query['diffonly'] = $diffOnly;
770        }
771        # Cascade unhide param in links for easy deletion browsing
772        if ( $this->unhide ) {
773            $query['unhide'] = 1;
774        }
775
776        # Check if one of the revisions is deleted/suppressed
777        $deleted = $this->hasDeletedRevision();
778        $suppressed = $this->hasSuppressedRevision();
779        $allowed = $this->isUserAllowedToSeeRevisions( $this->getAuthority() );
780
781        $revisionTools = [];
782        $breadCrumbs = '';
783
784        # mOldRevisionRecord is false if the difference engine is called with a "vague" query for
785        # a diff between a version V and its previous version V' AND the version V
786        # is the first version of that article. In that case, V' does not exist.
787        if ( $this->mOldRevisionRecord === false ) {
788            if ( $this->mNewPage ) {
789                $out->setPageTitleMsg(
790                    $this->msg( 'difference-title' )->plaintextParams( $this->mNewPage->getPrefixedText() )
791                );
792            }
793            $samePage = true;
794            $oldHeader = '';
795            // Allow extensions to change the $oldHeader variable
796            $this->hookRunner->onDifferenceEngineOldHeaderNoOldRev( $oldHeader );
797        } else {
798            $this->hookRunner->onDifferenceEngineViewHeader( $this );
799
800            if ( !$this->mOldPage || !$this->mNewPage ) {
801                // XXX say something to the user?
802                $samePage = false;
803            } elseif ( $this->mNewPage->equals( $this->mOldPage ) ) {
804                $out->setPageTitleMsg(
805                    $this->msg( 'difference-title' )->plaintextParams( $this->mNewPage->getPrefixedText() )
806                );
807                $samePage = true;
808            } else {
809                $out->setPageTitleMsg( $this->msg( 'difference-title-multipage' )->plaintextParams(
810                    $this->mOldPage->getPrefixedText(), $this->mNewPage->getPrefixedText() ) );
811                $out->addSubtitle( $this->msg( 'difference-multipage' ) );
812                $samePage = false;
813            }
814
815            if ( $samePage && $this->mNewPage &&
816                $this->getAuthority()->probablyCan( 'edit', $this->mNewPage )
817            ) {
818                if ( $this->mNewRevisionRecord->isCurrent() &&
819                    $this->getAuthority()->probablyCan( 'rollback', $this->mNewPage )
820                ) {
821                    $rollbackLink = Linker::generateRollback(
822                        $this->mNewRevisionRecord,
823                        $this->getContext(),
824                        [ 'noBrackets' ]
825                    );
826                    if ( $rollbackLink ) {
827                        $out->getMetadata()->setPreventClickjacking( true );
828                        $rollback = "\u{00A0}\u{00A0}\u{00A0}" . $rollbackLink;
829                    }
830                }
831
832                if ( $this->userCanEdit( $this->mOldRevisionRecord ) &&
833                    $this->userCanEdit( $this->mNewRevisionRecord )
834                ) {
835                    $undoLink = $this->linkRenderer->makeKnownLink(
836                        $this->mNewPage,
837                        $this->msg( 'editundo' )->text(),
838                        [ 'title' => Linker::titleAttrib( 'undo' ) ],
839                        [
840                            'action' => 'edit',
841                            'undoafter' => $this->mOldid,
842                            'undo' => $this->mNewid
843                        ]
844                    );
845                    $revisionTools['mw-diff-undo'] = $undoLink;
846                }
847            }
848            # Make "previous revision link"
849            $hasPrevious = $samePage && $this->mOldPage &&
850                $this->revisionStore->getPreviousRevision( $this->mOldRevisionRecord );
851            if ( $hasPrevious ) {
852                $prevlinkQuery = [ 'diff' => 'prev', 'oldid' => $this->mOldid ] + $query;
853                $prevlink = $this->linkRenderer->makeKnownLink(
854                    $this->mOldPage,
855                    $this->msg( 'previousdiff' )->text(),
856                    [ 'id' => 'differences-prevlink' ],
857                    $prevlinkQuery
858                );
859                $breadCrumbs .= $this->linkRenderer->makeKnownLink(
860                    $this->mOldPage,
861                    $this->msg( 'previousdiff' )->text(),
862                    [
863                        'class' => 'mw-diff-revision-history-link-previous'
864                    ],
865                    $prevlinkQuery
866                );
867            } else {
868                $prevlink = "\u{00A0}";
869            }
870
871            if ( $this->mOldRevisionRecord->isMinor() ) {
872                $oldminor = ChangesList::flag( 'minor' );
873            } else {
874                $oldminor = '';
875            }
876
877            $oldRevRecord = $this->mOldRevisionRecord;
878
879            $ldel = $this->revisionDeleteLink( $oldRevRecord );
880            $oldRevisionHeader = $this->getRevisionHeader( $oldRevRecord, 'complete' );
881            $oldChangeTags = ChangeTags::formatSummaryRow( $this->mOldTags, 'diff', $this->getContext() );
882            $oldRevComment = $this->commentFormatter
883                ->formatRevision(
884                    $oldRevRecord, $user, !$diffOnly, !$this->unhide, /** disable parentheseses */ false
885                );
886
887            if ( $oldRevComment === '' ) {
888                $defaultComment = $this->msg( 'changeslist-nocomment' )->escaped();
889                $oldRevComment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
890            }
891
892            $oldHeader = '<div id="mw-diff-otitle1"><strong>' . $oldRevisionHeader . '</strong></div>' .
893                '<div id="mw-diff-otitle2">' .
894                Linker::revUserTools( $oldRevRecord, !$this->unhide ) .
895                $this->getUserMetaData( $oldRevRecord->getUser() ) .
896                '</div>' .
897                '<div id="mw-diff-otitle3">' . $oldminor . $oldRevComment . $ldel . '</div>' .
898                '<div id="mw-diff-otitle5">' . $oldChangeTags[0] . '</div>' .
899                '<div id="mw-diff-otitle4">' . $prevlink . '</div>';
900
901            // Allow extensions to change the $oldHeader variable
902            $this->hookRunner->onDifferenceEngineOldHeader(
903                $this, $oldHeader, $prevlink, $oldminor, $diffOnly, $ldel, $this->unhide );
904        }
905
906        $out->addJsConfigVars( [
907            'wgDiffOldId' => $this->mOldid,
908            'wgDiffNewId' => $this->mNewid,
909        ] );
910
911        # Make "next revision link"
912        # Skip next link on the top revision
913        if ( $samePage && $this->mNewPage && !$this->mNewRevisionRecord->isCurrent() ) {
914            $nextlinkQuery = [ 'diff' => 'next', 'oldid' => $this->mNewid ] + $query;
915            $nextlink = $this->linkRenderer->makeKnownLink(
916                $this->mNewPage,
917                $this->msg( 'nextdiff' )->text(),
918                [ 'id' => 'differences-nextlink' ],
919                $nextlinkQuery
920            );
921            $breadCrumbs .= $this->linkRenderer->makeKnownLink(
922                $this->mNewPage,
923                $this->msg( 'nextdiff' )->text(),
924                [
925                    'class' => 'mw-diff-revision-history-link-next'
926                ],
927                $nextlinkQuery
928            );
929        } else {
930            $nextlink = "\u{00A0}";
931        }
932
933        if ( $this->mNewRevisionRecord->isMinor() ) {
934            $newminor = ChangesList::flag( 'minor' );
935        } else {
936            $newminor = '';
937        }
938
939        # Handle RevisionDelete links...
940        $rdel = $this->revisionDeleteLink( $this->mNewRevisionRecord );
941
942        # Allow extensions to define their own revision tools
943        $this->hookRunner->onDiffTools(
944            $this->mNewRevisionRecord,
945            $revisionTools,
946            $this->mOldRevisionRecord ?: null,
947            $user
948        );
949
950        $formattedRevisionTools = [];
951        // Put each one in parentheses (poor man's button)
952        foreach ( $revisionTools as $key => $tool ) {
953            $toolClass = is_string( $key ) ? $key : 'mw-diff-tool';
954            $element = Html::rawElement(
955                'span',
956                [ 'class' => $toolClass ],
957                $tool
958            );
959            $formattedRevisionTools[] = $element;
960        }
961
962        $newRevRecord = $this->mNewRevisionRecord;
963
964        $newRevisionHeader = $this->getRevisionHeader( $newRevRecord, 'complete' ) .
965            ' ' . implode( ' ', $formattedRevisionTools );
966        $newChangeTags = ChangeTags::formatSummaryRow( $this->mNewTags, 'diff', $this->getContext() );
967        $newRevComment = $this->commentFormatter->formatRevision(
968            $newRevRecord, $user, !$diffOnly, !$this->unhide, /** disable parentheseses */ false
969        );
970
971        if ( $newRevComment === '' ) {
972            $defaultComment = $this->msg( 'changeslist-nocomment' )->escaped();
973            $newRevComment = "<span class=\"comment mw-comment-none\">$defaultComment</span>";
974        }
975
976        $newHeader = '<div id="mw-diff-ntitle1"><strong>' . $newRevisionHeader . '</strong></div>' .
977            '<div id="mw-diff-ntitle2">' . Linker::revUserTools( $newRevRecord, !$this->unhide ) .
978            $rollback .
979            $this->getUserMetaData( $newRevRecord->getUser() ) .
980            '</div>' .
981            '<div id="mw-diff-ntitle3">' . $newminor . $newRevComment . $rdel . '</div>' .
982            '<div id="mw-diff-ntitle5">' . $newChangeTags[0] . '</div>' .
983            '<div id="mw-diff-ntitle4">' . $nextlink . $this->markPatrolledLink() . '</div>';
984
985        // Allow extensions to change the $newHeader variable
986        $this->hookRunner->onDifferenceEngineNewHeader( $this, $newHeader,
987            $formattedRevisionTools, $nextlink, $rollback, $newminor, $diffOnly,
988            $rdel, $this->unhide );
989
990        $out->addHTML(
991            Html::rawElement( 'div', [
992                'class' => 'mw-diff-revision-history-links'
993            ], $breadCrumbs )
994        );
995        $addMessageBoxStyles = false;
996        # If the diff cannot be shown due to a deleted revision, then output
997        # the diff header and links to unhide (if available)...
998        if ( $this->shouldBeHiddenFromUser( $this->getAuthority() ) ) {
999            $this->showDiffStyle();
1000            $multi = $this->getMultiNotice();
1001            $out->addHTML( $this->addHeader( '', $oldHeader, $newHeader, $multi ) );
1002            if ( !$allowed ) {
1003                # Give explanation for why revision is not visible
1004                $msg = [ $suppressed ? 'rev-suppressed-no-diff' : 'rev-deleted-no-diff' ];
1005            } else {
1006                # Give explanation and add a link to view the diff...
1007                $query = $this->getRequest()->appendQueryValue( 'unhide', '1' );
1008                $msg = [
1009                    $suppressed ? 'rev-suppressed-unhide-diff' : 'rev-deleted-unhide-diff',
1010                    $this->getTitle()->getFullURL( $query )
1011                ];
1012            }
1013            $out->addHTML( Html::warningBox( $this->msg( ...$msg )->parse(), 'plainlinks' ) );
1014            $addMessageBoxStyles = true;
1015        # Otherwise, output a regular diff...
1016        } else {
1017            # Add deletion notice if the user is viewing deleted content
1018            $notice = '';
1019            if ( $deleted ) {
1020                $msg = $suppressed ? 'rev-suppressed-diff-view' : 'rev-deleted-diff-view';
1021                $notice = Html::warningBox( $this->msg( $msg )->parse(), 'plainlinks' );
1022                $addMessageBoxStyles = true;
1023            }
1024
1025            # Add an error if the content can't be loaded
1026            $this->getSlotContents();
1027            foreach ( $this->getRevisionLoadErrors() as $msg ) {
1028                $notice .= Html::warningBox( $msg->parse() );
1029                $addMessageBoxStyles = true;
1030            }
1031
1032            // Check if inline switcher will be needed
1033            if ( $this->getTextDiffer()->hasFormat( 'inline' ) ) {
1034                $out->enableOOUI();
1035            }
1036
1037            $this->showTablePrefixes();
1038            $this->showDiff( $oldHeader, $newHeader, $notice );
1039            if ( !$diffOnly ) {
1040                $this->renderNewRevision();
1041            }
1042
1043            // Allow extensions to optionally not show the final patrolled link
1044            if ( $this->hookRunner->onDifferenceEngineRenderRevisionShowFinalPatrolLink() ) {
1045                # Add redundant patrol link on bottom...
1046                $out->addHTML( $this->markPatrolledLink() );
1047            }
1048        }
1049        if ( $addMessageBoxStyles ) {
1050            $out->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
1051        }
1052    }
1053
1054    /**
1055     * Add table prefixes
1056     */
1057    private function showTablePrefixes() {