Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
26.82% covered (danger)
26.82%
583 / 2174
12.63% covered (danger)
12.63%
12 / 95
CRAP
0.00% covered (danger)
0.00%
0 / 1
EditPage
26.83% covered (danger)
26.83%
583 / 2173
12.63% covered (danger)
12.63%
12 / 95
140221.25
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
2
 getArticle
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setContextTitle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getContextTitle
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 isSupportedContentModel
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 setApiEditOverride
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 edit
0.00% covered (danger)
0.00%
0 / 104
0.00% covered (danger)
0.00%
0 / 1
1190
 maybeActivateTempUserCreate
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
30
 createTempUser
15.38% covered (danger)
15.38%
2 / 13
0.00% covered (danger)
0.00%
0 / 1
8.45
 getAuthority
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUserForPermissions
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
5.26
 getUserForPreview
50.00% covered (danger)
50.00%
5 / 10
0.00% covered (danger)
0.00%
0 / 1
10.50
 getUserForSave
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
 getEditPermissionStatus
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 displayPermissionStatus
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
56
 displayViewSourcePage
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
30
 previewOnOpen
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
132
 isSectionEditSupported
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 importFormData
27.78% covered (danger)
27.78%
15 / 54
0.00% covered (danger)
0.00%
0 / 1
99.76
 importFormDataPosted
68.42% covered (warning)
68.42%
52 / 76
0.00% covered (danger)
0.00%
0 / 1
55.48
 importContentFormData
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 initialiseForm
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
240
 getContentObject
0.00% covered (danger)
0.00%
0 / 78
0.00% covered (danger)
0.00%
0 / 1
420
 generateUndoEditSummary
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
132
 getUndoContent
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
5.01
 getOriginalContent
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
3.47
 getParentRevId
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getCurrentContent
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
3
 tokenOk
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 setPostEditCookie
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 attemptSave
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 incrementResolvedConflicts
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 handleStatus
0.00% covered (danger)
0.00%
0 / 86
0.00% covered (danger)
0.00%
0 / 1
1560
 doPostEditRedirect
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
20
 setNewSectionSummary
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
3.00
 internalAttemptSave
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 internalAttemptSavePrivate
85.76% covered (warning)
85.76%
289 / 337
0.00% covered (danger)
0.00%
0 / 1
66.39
 handleFailedConstraint
58.82% covered (warning)
58.82%
10 / 17
0.00% covered (danger)
0.00%
0 / 1
19.45
 isUndoClean
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
8.05
 addContentModelChangeLogEntry
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 updateWatchlist
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 mergeChangesIntoContent
91.67% covered (success)
91.67%
22 / 24
0.00% covered (danger)
0.00%
0 / 1
7.03
 getExpectedParentRevision
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 setHeaders
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
210
 showIntro
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
20
 toEditText
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
6.97
 toEditContent
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 showEditForm
0.00% covered (danger)
0.00%
0 / 165
0.00% covered (danger)
0.00%
0 / 1
1122
 makeTemplatesOnThisPageList
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 extractSectionTitle
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 showHeader
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
552
 getSummaryInputAttributes
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 getSummaryInputWidget
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
6
 showSummaryInput
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 getSummaryPreview
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 showFormBeforeText
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 showFormAfterText
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 showContentForm
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 showTextbox1
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 showTextbox
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 displayPreviewArea
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 showPreview
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 showDiff
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
156
 showTosSummary
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 showEditTools
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 getCopyrightWarning
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
12
 getPreviewLimitReport
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
72
 showStandardInputs
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
30
 showConflict
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 incrementConflictStats
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getHelpLink
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 getCancelLink
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
20
 getActionURL
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 wasDeletedSinceLastEdit
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
13.78
 getLastDelete
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
30
 getPreviewText
0.00% covered (danger)
0.00%
0 / 90
0.00% covered (danger)
0.00%
0 / 1
812
 incrementEditFailureStats
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getPreviewParserOptions
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 doPreviewParse
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
2
 getTemplates
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
30
 getEditToolbar
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 getCheckboxesDefinition
55.56% covered (warning)
55.56%
10 / 18
0.00% covered (danger)
0.00%
0 / 1
5.40
 getCheckboxesDefinitionForWatchlist
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
30
 getCheckboxesWidget
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
20
 getSubmitButtonLabel
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 getEditButtons
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
6
 noSuchSectionPage
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 spamPageWithContent
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 addLongPageWarningHeader
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
56
 addExplainConflictHeader
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 guessSectionName
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 setEditConflictHelperFactory
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 getEditConflictHelper
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
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
21namespace MediaWiki\EditPage;
22
23use Article;
24use BadMethodCallException;
25use CategoryPage;
26use ErrorPageError;
27use LogPage;
28use ManualLogEntry;
29use MediaWiki\Auth\AuthManager;
30use MediaWiki\Block\BlockErrorFormatter;
31use MediaWiki\Cache\LinkBatchFactory;
32use MediaWiki\CommentStore\CommentStore;
33use MediaWiki\CommentStore\CommentStoreComment;
34use MediaWiki\Config\Config;
35use MediaWiki\Content\Content;
36use MediaWiki\Content\ContentHandler;
37use MediaWiki\Content\IContentHandlerFactory;
38use MediaWiki\Content\TextContent;
39use MediaWiki\Context\DerivativeContext;
40use MediaWiki\Context\IContextSource;
41use MediaWiki\Debug\DeprecationHelper;
42use MediaWiki\Deferred\DeferredUpdates;
43use MediaWiki\EditPage\Constraint\AccidentalRecreationConstraint;
44use MediaWiki\EditPage\Constraint\ChangeTagsConstraint;
45use MediaWiki\EditPage\Constraint\ContentModelChangeConstraint;
46use MediaWiki\EditPage\Constraint\DefaultTextConstraint;
47use MediaWiki\EditPage\Constraint\EditConstraintFactory;
48use MediaWiki\EditPage\Constraint\EditConstraintRunner;
49use MediaWiki\EditPage\Constraint\EditFilterMergedContentHookConstraint;
50use MediaWiki\EditPage\Constraint\ExistingSectionEditConstraint;
51use MediaWiki\EditPage\Constraint\IEditConstraint;
52use MediaWiki\EditPage\Constraint\ImageRedirectConstraint;
53use MediaWiki\EditPage\Constraint\MissingCommentConstraint;
54use MediaWiki\EditPage\Constraint\NewSectionMissingSubjectConstraint;
55use MediaWiki\EditPage\Constraint\PageSizeConstraint;
56use MediaWiki\EditPage\Constraint\SelfRedirectConstraint;
57use MediaWiki\EditPage\Constraint\SpamRegexConstraint;
58use MediaWiki\EditPage\Constraint\UnicodeConstraint;
59use MediaWiki\EditPage\Constraint\UserBlockConstraint;
60use MediaWiki\HookContainer\HookRunner;
61use MediaWiki\HookContainer\ProtectedHookAccessorTrait;
62use MediaWiki\Html\Html;
63use MediaWiki\Language\RawMessage;
64use MediaWiki\Linker\Linker;
65use MediaWiki\Linker\LinkRenderer;
66use MediaWiki\Logger\LoggerFactory;
67use MediaWiki\MainConfigNames;
68use MediaWiki\MediaWikiServices;
69use MediaWiki\Message\Message;
70use MediaWiki\Page\PageIdentity;
71use MediaWiki\Page\PageReference;
72use MediaWiki\Page\RedirectLookup;
73use MediaWiki\Page\WikiPageFactory;
74use MediaWiki\Parser\ParserOptions;
75use MediaWiki\Parser\ParserOutput;
76use MediaWiki\Parser\ParserOutputLinkTypes;
77use MediaWiki\Permissions\Authority;
78use MediaWiki\Permissions\PermissionManager;
79use MediaWiki\Permissions\PermissionStatus;
80use MediaWiki\Permissions\RestrictionStore;
81use MediaWiki\Request\WebRequest;
82use MediaWiki\Revision\RevisionRecord;
83use MediaWiki\Revision\RevisionStore;
84use MediaWiki\Revision\RevisionStoreRecord;
85use MediaWiki\Revision\SlotRecord;
86use MediaWiki\Status\Status;
87use MediaWiki\Storage\EditResult;
88use MediaWiki\Title\Title;
89use MediaWiki\User\ExternalUserNames;
90use MediaWiki\User\Options\UserOptionsLookup;
91use MediaWiki\User\TempUser\CreateStatus;
92use MediaWiki\User\TempUser\TempUserCreator;
93use MediaWiki\User\User;
94use MediaWiki\User\UserFactory;
95use MediaWiki\User\UserIdentity;
96use MediaWiki\User\UserNameUtils;
97use MediaWiki\Watchlist\WatchedItemStoreInterface;
98use MediaWiki\Watchlist\WatchlistManager;
99use MessageLocalizer;
100use MWContentSerializationException;
101use MWException;
102use MWUnknownContentModelException;
103use OOUI;
104use OOUI\ButtonWidget;
105use OOUI\CheckboxInputWidget;
106use OOUI\DropdownInputWidget;
107use OOUI\FieldLayout;
108use PermissionsError;
109use ReadOnlyError;
110use RecentChange;
111use RuntimeException;
112use Skin;
113use stdClass;
114use ThrottledError;
115use UserBlockedError;
116use WatchAction;
117use Wikimedia\Assert\Assert;
118use Wikimedia\Message\MessageValue;
119use Wikimedia\ParamValidator\TypeDef\ExpiryDef;
120use Wikimedia\Rdbms\IConnectionProvider;
121use Wikimedia\Rdbms\IDBAccessObject;
122use Wikimedia\Rdbms\SelectQueryBuilder;
123use WikiPage;
124
125/**
126 * The HTML user interface for page editing.
127 *
128 * This was originally split from the Article class, with some database and text
129 * munging logic still partly there.
130 *
131 * EditPage cares about two distinct titles:
132 * - $this->mContextTitle is the page that forms submit to, links point to,
133 *   redirects go to, etc.
134 * - $this->mTitle (as well as $mArticle) is the page in the database that is
135 *   actually being edited.
136 *
137 * These are usually the same, but they are now allowed to be different.
138 *
139 * Surgeon General's Warning: prolonged exposure to this class is known to cause
140 * headaches, which may be fatal.
141 *
142 * @newable
143 * @note marked as newable in 1.35 for lack of a better alternative,
144 *       but should be split up into service objects and command objects
145 *       in the future (T157658).
146 */
147#[\AllowDynamicProperties]
148class EditPage implements IEditObject {
149    use DeprecationHelper;
150    use ProtectedHookAccessorTrait;
151
152    /**
153     * Used for Unicode support checks
154     */
155    public const UNICODE_CHECK = UnicodeConstraint::VALID_UNICODE;
156
157    /**
158     * HTML id and name for the beginning of the edit form.
159     */
160    public const EDITFORM_ID = 'editform';
161
162    /**
163     * Prefix of key for cookie used to pass post-edit state.
164     * The revision id edited is added after this
165     */
166    public const POST_EDIT_COOKIE_KEY_PREFIX = 'PostEditRevision';
167
168    /**
169     * Duration of PostEdit cookie, in seconds.
170     * The cookie will be removed on the next page view of this article (Article::view()).
171     *
172     * Otherwise, though, we don't want the cookies to accumulate.
173     * RFC 2109 ( https://www.ietf.org/rfc/rfc2109.txt ) specifies a possible
174     * limit of only 20 cookies per domain. This still applies at least to some
175     * versions of IE without full updates:
176     * https://blogs.msdn.com/b/ieinternals/archive/2009/08/20/wininet-ie-cookie-internals-faq.aspx
177     *
178     * A value of 20 minutes should be enough to take into account slow loads and minor
179     * clock skew while still avoiding cookie accumulation when JavaScript is turned off.
180     *
181     * Some say this is too long (T211233), others say it is too short (T289538).
182     * The same value is used for client-side post-edit storage (in mediawiki.action.view.postEdit).
183     */
184    public const POST_EDIT_COOKIE_DURATION = 1200;
185
186    /**
187     * @var Article
188     */
189    private $mArticle;
190
191    /** @var WikiPage */
192    private $page;
193
194    /**
195     * @var Title
196     */
197    private $mTitle;
198
199    /** @var null|Title */
200    private $mContextTitle = null;
201
202    /**
203     * @deprecated since 1.38 for public usage; no replacement
204     * @var string
205     */
206    public $action = 'submit';
207
208    /** @var bool Whether an edit conflict needs to be resolved. Detected based on whether
209     * $editRevId is different than the latest revision. When a conflict has successfully
210     * been resolved by a 3-way-merge, this field is set to false.
211     */
212    public $isConflict = false;
213
214    /** @var bool New page or new section */
215    private $isNew = false;
216
217    /** @var bool */
218    private $deletedSinceEdit;
219
220    /** @var string */
221    public $formtype;
222
223    /** @var bool
224     * True the first time the edit form is rendered, false after re-rendering
225     * with diff, save prompts, etc.
226     */
227    public $firsttime;
228
229    /** @var stdClass|null */
230    private $lastDelete;
231
232    /** @var bool */
233    private $mTokenOk = false;
234
235    /** @var bool */
236    private $mTriedSave = false;
237
238    /** @var bool */
239    private $incompleteForm = false;
240
241    /** @var bool */
242    private $tooBig = false;
243
244    /** @var bool */
245    private $missingComment = false;
246
247    /** @var bool */
248    private $missingSummary = false;
249
250    /** @var bool */
251    private $allowBlankSummary = false;
252
253    /** @var bool */
254    protected $blankArticle = false;
255
256    /** @var bool */
257    private $allowBlankArticle = false;
258
259    /** @var bool */
260    private $selfRedirect = false;
261
262    /** @var bool */
263    private $allowSelfRedirect = false;
264
265    /** @var string */
266    private $autoSumm = '';
267
268    /** @var string */
269    private $hookError = '';
270
271    /** @var ParserOutput */
272    private $mParserOutput;
273
274    /**
275     * @var RevisionRecord|false|null
276     *
277     * A RevisionRecord corresponding to $this->editRevId or $this->edittime
278     */
279    private $mExpectedParentRevision = false;
280
281    /** @var bool */
282    public $mShowSummaryField = true;
283
284    # Form values
285
286    /** @var bool */
287    public $save = false;
288
289    /** @var bool */
290    public $preview = false;
291
292    /** @var bool */
293    private $diff = false;
294
295    /** @var bool */
296    private $minoredit = false;
297
298    /** @var bool */
299    private $watchthis = false;
300
301    /** @var bool Corresponds to $wgWatchlistExpiry */
302    private $watchlistExpiryEnabled;
303
304    private WatchedItemStoreInterface $watchedItemStore;
305
306    /** @var string|null The expiry time of the watch item, or null if it is not watched temporarily. */
307    private $watchlistExpiry;
308
309    /** @var bool */
310    private $recreate = false;
311
312    /** @var string
313     * Page content input field.
314     */
315    public $textbox1 = '';
316
317    /** @var string */
318    public $textbox2 = '';
319
320    /** @var string */
321    public $summary = '';
322
323    /**
324     * @var bool
325     * If true, hide the summary field.
326     */
327    private $nosummary = false;
328
329    /** @var string|null
330     * Timestamp of the latest revision of the page when editing was initiated
331     * on the client.
332     */
333    public $edittime = '';
334
335    /** @var int|null Revision ID of the latest revision of the page when editing
336     * was initiated on the client.  This is used to detect and resolve edit
337     * conflicts.
338     *
339     * @note 0 if the page did not exist at that time.
340     * @note When starting an edit from an old revision, this still records the current
341     * revision at the time, not the one the edit is based on.
342     *
343     * @see $oldid
344     * @see getExpectedParentRevision()
345     */
346    private $editRevId = null;
347
348    /** @var string */
349    public $section = '';
350
351    /** @var string|null */
352    public $sectiontitle = null;
353
354    /** @var string|null */
355    private $newSectionAnchor = null;
356
357    /** @var string|null
358     * Timestamp from the first time the edit form was rendered.
359     */
360    public $starttime = '';
361
362    /** @var int Revision ID the edit is based on, or 0 if it's the current revision.
363     * FIXME: This isn't used in conflict resolution--provide a better
364     * justification or merge with parentRevId.
365     * @see $editRevId
366     */
367    public $oldid = 0;
368
369    /**
370     * @var int Revision ID the edit is based on, adjusted when an edit conflict is resolved.
371     * @see $editRevId
372     * @see $oldid
373     * @see getparentRevId()
374     */
375    private $parentRevId = 0;
376
377    /** @var int|null */
378    private $scrolltop = null;
379
380    /** @var bool */
381    private $markAsBot = true;
382
383    /** @var string */
384    public $contentModel;
385
386    /** @var null|string */
387    public $contentFormat = null;
388
389    /** @var null|array */
390    private $changeTags = null;
391
392    # Placeholders for text injection by hooks (must be HTML)
393    # extensions should take care to _append_ to the present value
394
395    /** @var string Before even the preview */
396    public $editFormPageTop = '';
397    /** @var string */
398    public $editFormTextTop = '';
399    /** @var string */
400    public $editFormTextBeforeContent = '';
401    /** @var string */
402    public $editFormTextAfterWarn = '';
403    /** @var string */
404    public $editFormTextAfterTools = '';
405    /** @var string */
406    public $editFormTextBottom = '';
407    /** @var string */
408    public $editFormTextAfterContent = '';
409    /** @var string */
410    public $previewTextAfterContent = '';
411
412    /** @var bool should be set to true whenever an article was successfully altered. */
413    public $didSave = false;
414    /** @var int */
415    public $undidRev = 0;
416    /** @var int */
417    private $undoAfter = 0;
418
419    /** @var bool */
420    public $suppressIntro = false;
421
422    /** @var bool */
423    private $edit;
424
425    /** @var int|false */
426    private $contentLength = false;
427
428    /**
429     * @var bool Set in ApiEditPage, based on ContentHandler::allowsDirectApiEditing
430     */
431    private $enableApiEditOverride = false;
432
433    /**
434     * @var IContextSource
435     */
436    protected $context;
437
438    /**
439     * @var bool Whether an old revision is edited
440     */
441    private $isOldRev = false;
442
443    /**
444     * @var string|null What the user submitted in the 'wpUnicodeCheck' field
445     */
446    private $unicodeCheck;
447
448    /** @var callable|null */
449    private $editConflictHelperFactory = null;
450    private ?TextConflictHelper $editConflictHelper = null;
451
452    private IContentHandlerFactory $contentHandlerFactory;
453    private PermissionManager $permManager;
454    private RevisionStore $revisionStore;
455    private WikiPageFactory $wikiPageFactory;
456    private WatchlistManager $watchlistManager;
457    private UserNameUtils $userNameUtils;
458    private RedirectLookup $redirectLookup;
459    private UserOptionsLookup $userOptionsLookup;
460    private TempUserCreator $tempUserCreator;
461    private UserFactory $userFactory;
462    private IConnectionProvider $dbProvider;
463    private BlockErrorFormatter $blockErrorFormatter;
464    private AuthManager $authManager;
465
466    /** @var User|null */
467    private $placeholderTempUser;
468
469    /** @var User|null */
470    private $unsavedTempUser;
471
472    /** @var User|null */
473    private $savedTempUser;
474
475    /** @var bool Whether temp user creation will be attempted */
476    private $tempUserCreateActive = false;
477
478    /** @var string|null If a temp user name was acquired, this is the name */
479    private $tempUserName;
480
481    /** @var bool Whether temp user creation was successful */
482    private $tempUserCreateDone = false;
483
484    /** @var bool Whether temp username acquisition failed (false indicates no failure or not attempted) */
485    private $unableToAcquireTempName = false;
486
487    private LinkRenderer $linkRenderer;
488    private LinkBatchFactory $linkBatchFactory;
489    private RestrictionStore $restrictionStore;
490    private CommentStore $commentStore;
491
492    /**
493     * @stable to call
494     * @param Article $article
495     */
496    public function __construct( Article $article ) {
497        $this->mArticle = $article;
498        $this->page = $article->getPage(); // model object
499        $this->mTitle = $article->getTitle();
500
501        // Make sure the local context is in sync with other member variables.
502        // Particularly make sure everything is using the same WikiPage instance.
503        // This should probably be the case in Article as well, but it's
504        // particularly important for EditPage, to make use of the in-place caching
505        // facility in WikiPage::prepareContentForEdit.
506        $this->context = new DerivativeContext( $article->getContext() );
507        $this->context->setWikiPage( $this->page );
508        $this->context->setTitle( $this->mTitle );
509
510        $this->contentModel = $this->mTitle->getContentModel();
511
512        $services = MediaWikiServices::getInstance();
513        $this->contentHandlerFactory = $services->getContentHandlerFactory();
514        $this->contentFormat = $this->contentHandlerFactory
515            ->getContentHandler( $this->contentModel )
516            ->getDefaultFormat();
517        $this->permManager = $services->getPermissionManager();
518        $this->revisionStore = $services->getRevisionStore();
519        $this->watchlistExpiryEnabled = $this->getContext()->getConfig() instanceof Config
520            && $this->getContext()->getConfig()->get( MainConfigNames::WatchlistExpiry );
521        $this->watchedItemStore = $services->getWatchedItemStore();
522        $this->wikiPageFactory = $services->getWikiPageFactory();
523        $this->watchlistManager = $services->getWatchlistManager();
524        $this->userNameUtils = $services->getUserNameUtils();
525        $this->redirectLookup = $services->getRedirectLookup();
526        $this->userOptionsLookup = $services->getUserOptionsLookup();
527        $this->tempUserCreator = $services->getTempUserCreator();
528        $this->userFactory = $services->getUserFactory();
529        $this->linkRenderer = $services->getLinkRenderer();
530        $this->linkBatchFactory = $services->getLinkBatchFactory();
531        $this->restrictionStore = $services->getRestrictionStore();
532        $this->commentStore = $services->getCommentStore();
533        $this->dbProvider = $services->getConnectionProvider();
534        $this->blockErrorFormatter = $services->getFormatterFactory()
535            ->getBlockErrorFormatter( $this->context );
536        $this->authManager = $services->getAuthManager();
537
538        // XXX: Restore this deprecation as soon as TwoColConflict is fixed (T305028)
539        // $this->deprecatePublicProperty( 'textbox2', '1.38', __CLASS__ );
540    }
541
542    /**
543     * @return Article
544     */
545    public function getArticle() {
546        return $this->mArticle;
547    }
548
549    /**
550     * @since 1.28
551     * @return IContextSource
552     */
553    public function getContext() {
554        return $this->context;
555    }
556
557    /**
558     * @since 1.19
559     * @return Title
560     */
561    public function getTitle() {
562        return $this->mTitle;
563    }
564
565    /**
566     * @param Title|null $title
567     */
568    public function setContextTitle( $title ) {
569        $this->mContextTitle = $title;
570    }
571
572    /**
573     * @throws RuntimeException if no context title was set
574     * @return Title
575     */
576    public function getContextTitle() {
577        if ( $this->mContextTitle === null ) {
578            throw new RuntimeException( "EditPage does not have a context title set" );
579        } else {
580            return $this->mContextTitle;
581        }
582    }
583
584    /**
585     * Returns if the given content model is editable.
586     *
587     * @param string $modelId The ID of the content model to test. Use CONTENT_MODEL_XXX constants.
588     * @return bool
589     * @throws MWUnknownContentModelException If $modelId has no known handler
590     */
591    private function isSupportedContentModel( string $modelId ): bool {
592        return $this->enableApiEditOverride === true ||
593            $this->contentHandlerFactory->getContentHandler( $modelId )->supportsDirectEditing();
594    }
595
596    /**
597     * Allow editing of content that supports API direct editing, but not general
598     * direct editing. Set to false by default.
599     * @internal Must only be used by ApiEditPage
600     *
601     * @param bool $enableOverride
602     */
603    public function setApiEditOverride( $enableOverride ) {
604        $this->enableApiEditOverride = $enableOverride;
605    }
606
607    /**
608     * This is the function that gets called for "action=edit". It
609     * sets up various member variables, then passes execution to
610     * another function, usually showEditForm()
611     *
612     * The edit form is self-submitting, so that when things like
613     * preview and edit conflicts occur, we get the same form back
614     * with the extra stuff added.  Only when the final submission
615     * is made and all is well do we actually save and redirect to
616     * the newly-edited page.
617     */
618    public function edit() {
619        // Allow extensions to modify/prevent this form or submission
620        if ( !$this->getHookRunner()->onAlternateEdit( $this ) ) {
621            return;
622        }
623
624        wfDebug( __METHOD__ . ": enter" );
625
626        $request = $this->context->getRequest();
627        // If they used redlink=1 and the page exists, redirect to the main article
628        if ( $request->getBool( 'redlink' ) && $this->mTitle->exists() ) {
629            $this->context->getOutput()->redirect( $this->mTitle->getFullURL() );
630            return;
631        }
632
633        $this->importFormData( $request );
634        $this->firsttime = false;
635
636        $readOnlyMode = MediaWikiServices::getInstance()->getReadOnlyMode();
637        if ( $this->save && $readOnlyMode->isReadOnly() ) {
638            // Force preview
639            $this->save = false;
640            $this->preview = true;
641        }
642
643        if ( $this->save ) {
644            $this->formtype = 'save';
645        } elseif ( $this->preview ) {
646            $this->formtype = 'preview';
647        } elseif ( $this->diff ) {
648            $this->formtype = 'diff';
649        } else { # First time through
650            $this->firsttime = true;
651            if ( $this->previewOnOpen() ) {
652                $this->formtype = 'preview';
653            } else {
654                $this->formtype = 'initial';
655            }
656        }
657
658        // Check permissions after possibly creating a placeholder temp user.
659        // This allows anonymous users to edit via a temporary account, if the site is
660        // configured to (1) disallow anonymous editing and (2) autocreate temporary
661        // accounts on edit.
662        $this->unableToAcquireTempName = !$this->maybeActivateTempUserCreate( !$this->firsttime )->isOK();
663
664        $status = $this->getEditPermissionStatus(
665            $this->save ? PermissionManager::RIGOR_SECURE : PermissionManager::RIGOR_FULL
666        );
667        if ( !$status->isGood() ) {
668            wfDebug( __METHOD__ . ": User can't edit" );
669
670            $user = $this->context->getUser();
671            if ( $user->getBlock() && !$readOnlyMode->isReadOnly() ) {
672                // Auto-block user's IP if the account was "hard" blocked
673                DeferredUpdates::addCallableUpdate( static function () use ( $user ) {
674                    $user->spreadAnyEditBlock();
675                } );
676            }
677            $this->displayPermissionStatus( $status );
678
679            return;
680        }
681
682        $revRecord = $this->mArticle->fetchRevisionRecord();
683        // Disallow editing revisions with content models different from the current one
684        // Undo edits being an exception in order to allow reverting content model changes.
685        $revContentModel = $revRecord ?
686            $revRecord->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getModel() :
687            false;
688        if ( $revContentModel && $revContentModel !== $this->contentModel ) {
689            $prevRevRecord = null;
690            $prevContentModel = false;
691            if ( $this->undidRev ) {
692                $undidRevRecord = $this->revisionStore
693                    ->getRevisionById( $this->undidRev );
694                $prevRevRecord = $undidRevRecord ?
695                    $this->revisionStore->getPreviousRevision( $undidRevRecord ) :
696                    null;
697
698                $prevContentModel = $prevRevRecord ?
699                    $prevRevRecord
700                        ->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )
701                        ->getModel() :
702                    '';
703            }
704
705            if ( !$this->undidRev
706                || !$prevRevRecord
707                || $prevContentModel !== $this->contentModel
708            ) {
709                $this->displayViewSourcePage(
710                    $this->getContentObject(),
711                    $this->context->msg(
712                        'contentmodelediterror',
713                        $revContentModel,
714                        $this->contentModel
715                    )->plain()
716                );
717                return;
718            }
719        }
720
721        $this->isConflict = false;
722
723        # Attempt submission here.  This will check for edit conflicts,
724        # and redundantly check for locked database, blocked IPs, etc.
725        # that edit() already checked just in case someone tries to sneak
726        # in the back door with a hand-edited submission URL.
727
728        if ( $this->formtype === 'save' ) {
729            $resultDetails = null;
730            $status = $this->attemptSave( $resultDetails );
731            if ( !$this->handleStatus( $status, $resultDetails ) ) {
732                return;
733            }
734        }
735
736        # First time through: get contents, set time for conflict
737        # checking, etc.
738        if ( $this->formtype === 'initial' || $this->firsttime ) {
739            if ( !$this->initialiseForm() ) {
740                return;
741            }
742
743            if ( $this->mTitle->getArticleID() ) {
744                $this->getHookRunner()->onEditFormInitialText( $this );
745            }
746        }
747
748        // If we're displaying an old revision, and there are differences between it and the
749        // current revision outside the main slot, then we can't allow the old revision to be
750        // editable, as what would happen to the non-main-slot data if someone saves the old
751        // revision is undefined.
752        // When this is the case, display a read-only version of the page instead, with a link
753        // to a diff page from which the old revision can be restored
754        $curRevisionRecord = $this->page->getRevisionRecord();
755        if ( $curRevisionRecord
756            && $revRecord
757            && $curRevisionRecord->getId() !== $revRecord->getId()
758            && ( WikiPage::hasDifferencesOutsideMainSlot(
759                    $revRecord,
760                    $curRevisionRecord
761                ) || !$this->isSupportedContentModel(
762                    $revRecord->getSlot(
763                        SlotRecord::MAIN,
764                        RevisionRecord::RAW
765                    )->getModel()
766                ) )
767        ) {
768            $restoreLink = $this->mTitle->getFullURL(
769                [
770                    'action' => 'mcrrestore',
771                    'restore' => $revRecord->getId(),
772                ]
773            );
774            $this->displayViewSourcePage(
775                $this->getContentObject(),
776                $this->context->msg(
777                    'nonmain-slot-differences-therefore-readonly',
778                    $restoreLink
779                )->plain()
780            );
781            return;
782        }
783
784        $this->showEditForm();
785    }
786
787    /**
788     * Check the configuration and current user and enable automatic temporary
789     * user creation if possible.
790     *
791     * @param bool $doAcquire Whether to acquire a name for the temporary account
792     *
793     * @since 1.39
794     * @return Status Will return a fatal status if $doAcquire was true and the acquire failed.
795     */
796    public function maybeActivateTempUserCreate( $doAcquire ): Status {
797        if ( $this->tempUserCreateActive ) {
798            // Already done
799            return Status::newGood();
800        }
801        $user = $this->context->getUser();
802        if ( $this->tempUserCreator->shouldAutoCreate( $user, 'edit' ) ) {
803            if ( $doAcquire ) {
804                $name = $this->tempUserCreator->acquireAndStashName(
805                    $this->context->getRequest()->getSession() );
806                if ( $name === null ) {
807                    $status = Status::newFatal( 'temp-user-unable-to-acquire' );
808                    $status->value = self::AS_UNABLE_TO_ACQUIRE_TEMP_ACCOUNT;
809                    return $status;
810                }
811                $this->unsavedTempUser = $this->userFactory->newUnsavedTempUser( $name );
812                $this->tempUserName = $name;
813            } else {
814                $this->placeholderTempUser = $this->userFactory->newTempPlaceholder();
815            }
816            $this->tempUserCreateActive = true;
817        }
818        return Status::newGood();
819    }
820
821    /**
822     * If automatic user creation is enabled, create the user.
823     *
824     * This is a helper for internalAttemptSavePrivate().
825     *
826     * If the edit is a null edit, the user will not be created.
827     *
828     * @return Status
829     */
830    private function createTempUser(): Status {
831        if ( !$this->tempUserCreateActive ) {
832            return Status::newGood();
833        }
834        $status = $this->tempUserCreator->create(
835            $this->tempUserName,
836            $this->context->getRequest()
837        );
838        if ( $status->isOK() ) {
839            $this->placeholderTempUser = null;
840            $this->unsavedTempUser = null;
841            $this->savedTempUser = $status->getUser();
842            $this->authManager->setRequestContextUserFromSessionUser();
843            $this->tempUserCreateDone = true;
844        }
845        return $status;
846    }
847
848    /**
849     * Get the authority for permissions purposes.
850     *
851     * On an initial edit page GET request, if automatic temporary user creation
852     * is enabled, this may be a placeholder user with a fixed name. Such users
853     * are unsuitable for anything that uses or exposes the name, like
854     * throttling. The only thing a placeholder user is good for is fooling the
855     * permissions system into allowing edits by anons.
856     *
857     * @return Authority
858     */
859    private function getAuthority(): Authority {
860        return $this->getUserForPermissions();
861    }
862
863    /**
864     * Get the user for permissions purposes, with declared type User instead
865     * of Authority for compatibility with PermissionManager.
866     *
867     * @return User
868     */
869    private function getUserForPermissions() {
870        if ( $this->savedTempUser ) {
871            return $this->savedTempUser;
872        } elseif ( $this->unsavedTempUser ) {
873            return $this->unsavedTempUser;
874        } elseif ( $this->placeholderTempUser ) {
875            return $this->placeholderTempUser;
876        } else {
877            return $this->context->getUser();
878        }
879    }
880
881    /**
882     * Get the user for preview or PST purposes. During the temporary user
883     * creation flow this may be an unsaved temporary user.
884     *
885     * @return User
886     */
887    private function getUserForPreview() {
888        if ( $this->savedTempUser ) {
889            return $this->savedTempUser;
890        } elseif ( $this->unsavedTempUser ) {
891            return $this->unsavedTempUser;
892        } elseif ( $this->firsttime && $this->placeholderTempUser ) {
893            // Mostly a GET request and no temp user was aquired,
894            // but needed for pst or content transform for preview,
895            // fallback to a placeholder for this situation (T330943)
896            return $this->placeholderTempUser;
897        } elseif ( $this->tempUserCreateActive ) {
898            throw new BadMethodCallException(
899                "Can't use the request user for preview with IP masking enabled" );
900        } else {
901            return $this->context->getUser();
902        }
903    }
904
905    /**
906     * Get the user suitable for permanent attribution in the database. This
907     * asserts that an anonymous user won't be used in IP masking mode.
908     *
909     * @return User
910     */
911    private function getUserForSave() {
912        if ( $this->savedTempUser ) {
913            return $this->savedTempUser;
914        } elseif ( $this->tempUserCreateActive ) {
915            throw new BadMethodCallException(
916                "Can't use the request user for storage with IP masking enabled" );
917        } else {
918            return $this->context->getUser();
919        }
920    }
921
922    /**
923     * @param string $rigor PermissionManager::RIGOR_ constant
924     * @return PermissionStatus
925     */
926    private function getEditPermissionStatus( string $rigor = PermissionManager::RIGOR_SECURE ): PermissionStatus {
927        $user = $this->getUserForPermissions();
928        return $this->permManager->getPermissionStatus(
929            'edit',
930            $user,
931            $this->mTitle,
932            $rigor
933        );
934    }
935
936    /**
937     * Display a permissions error page, like OutputPage::showPermissionStatus(),
938     * but with the following differences:
939     * - If redlink=1, the user will be redirected to the page
940     * - If there is content to display or the error occurs while either saving,
941     *   previewing or showing the difference, it will be a
942     *   "View source for ..." page displaying the source code after the error message.
943     *
944     * @param PermissionStatus $status Permissions errors
945     * @throws PermissionsError
946     */
947    private function displayPermissionStatus( PermissionStatus $status ): void {
948        $out = $this->context->getOutput();
949        if ( $this->context->getRequest()->getBool( 'redlink' ) ) {
950            // The edit page was reached via a red link.
951            // Redirect to the article page and let them click the edit tab if
952            // they really want a permission error.
953            $out->redirect( $this->mTitle->getFullURL() );
954            return;
955        }
956
957        $content = $this->getContentObject();
958
959        // Use the normal message if there's nothing to display
960        // We used to only do this if $this->firsttime was truthy, and there was no content
961        // or the content was empty, but sometimes there was no content even if it not the
962        // first time, we can't use displayViewSourcePage if there is no content (T281400)
963        if ( !$content || ( $this->firsttime && $content->isEmpty() ) ) {
964            $action = $this->mTitle->exists() ? 'edit' :
965                ( $this->mTitle->isTalkPage() ? 'createtalk' : 'createpage' );
966            throw new PermissionsError( $action, $status );
967        }
968
969        $this->displayViewSourcePage(
970            $content,
971            $out->formatPermissionStatus( $status, 'edit' )
972        );
973    }
974
975    /**
976     * Display a read-only View Source page
977     * @param Content $content
978     * @param string $errorMessage additional wikitext error message to display
979     */
980    private function displayViewSourcePage( Content $content, string $errorMessage ): void {
981        $out = $this->context->getOutput();
982        $this->getHookRunner()->onEditPage__showReadOnlyForm_initial( $this, $out );
983
984        $out->setRobotPolicy( 'noindex,nofollow' );
985        $out->setPageTitleMsg( $this->context->msg(
986            'viewsource-title'
987        )->plaintextParams(
988            $this->getContextTitle()->getPrefixedText()
989        ) );
990        $out->addBacklinkSubtitle( $this->getContextTitle() );
991        $out->addHTML( $this->editFormPageTop );
992        $out->addHTML( $this->editFormTextTop );
993
994        if ( $errorMessage !== '' ) {
995            $out->addWikiTextAsInterface( $errorMessage );
996            $out->addHTML( "<hr />\n" );
997        }
998
999        # If the user made changes, preserve them when showing the markup
1000        # (This happens when a user is blocked during edit, for instance)
1001        if ( !$this->firsttime ) {
1002            $text = $this->textbox1;
1003            $out->addWikiMsg( 'viewyourtext' );
1004        } else {
1005            try {
1006                $text = $this->toEditText( $content );
1007            } catch ( MWException $e ) {
1008                # Serialize using the default format if the content model is not supported
1009                # (e.g. for an old revision with a different model)
1010                $text = $content->serialize();
1011            }
1012            $out->addWikiMsg( 'viewsourcetext' );
1013        }
1014
1015        $out->addHTML( $this->editFormTextBeforeContent );
1016        $this->showTextbox( $text, 'wpTextbox1', [ 'readonly' ] );
1017        $out->addHTML( $this->editFormTextAfterContent );
1018
1019        $out->addHTML( $this->makeTemplatesOnThisPageList( $this->getTemplates() ) );
1020
1021        $out->addModules( 'mediawiki.action.edit.collapsibleFooter' );
1022
1023        $out->addHTML( $this->editFormTextBottom );
1024        if ( $this->mTitle->exists() ) {
1025            $out->returnToMain( null, $this->mTitle );
1026        }
1027    }
1028
1029    /**
1030     * Should we show a preview when the edit form is first shown?
1031     *
1032     * @return bool
1033     */
1034    protected function previewOnOpen() {
1035        $config = $this->context->getConfig();
1036        $previewOnOpenNamespaces = $config->get( MainConfigNames::PreviewOnOpenNamespaces );
1037        $request = $this->context->getRequest();
1038        if ( $config->get( MainConfigNames::RawHtml ) ) {
1039            // If raw HTML is enabled, disable preview on open
1040            // since it has to be posted with a token for
1041            // security reasons
1042            return false;
1043        }
1044        if ( $request->getVal( 'preview' ) === 'yes' ) {
1045            // Explicit override from request
1046            return true;
1047        } elseif ( $request->getVal( 'preview' ) === 'no' ) {
1048            // Explicit override from request
1049            return false;
1050        } elseif ( $this->section === 'new' ) {
1051            // Nothing *to* preview for new sections
1052            return false;
1053        } elseif ( ( $request->getCheck( 'preload' ) || $this->mTitle->exists() )
1054            && $this->userOptionsLookup->getOption( $this->context->getUser(), 'previewonfirst' )
1055        ) {
1056            // Standard preference behavior
1057            return true;
1058        } elseif ( !$this->mTitle->exists()
1059            && isset( $previewOnOpenNamespaces[$this->mTitle->getNamespace()] )
1060            && $previewOnOpenNamespaces[$this->mTitle->getNamespace()]
1061        ) {
1062            // Categories are special
1063            return true;
1064        } else {
1065            return false;
1066        }
1067    }
1068
1069    /**
1070     * Section editing is supported when the page content model allows
1071     * section edit and we are editing current revision.
1072     *
1073     * @return bool True if this edit page supports sections, false otherwise.
1074     */
1075    private function isSectionEditSupported(): bool {
1076        $currentRev = $this->page->getRevisionRecord();
1077
1078        // $currentRev is null for non-existing pages, use the page default content model.
1079        $revContentModel = $currentRev
1080            ? $currentRev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getModel()
1081            : $this->page->getContentModel();
1082
1083        return (
1084            ( $this->mArticle->getRevIdFetched() === $this->page->getLatest() ) &&
1085            $this->contentHandlerFactory->getContentHandler( $revContentModel )->supportsSections()
1086        );
1087    }
1088
1089    /**
1090     * This function collects the form data and uses it to populate various member variables.
1091     * @param WebRequest &$request
1092     * @throws ErrorPageError
1093     */
1094    public function importFormData( &$request ) {
1095        # Section edit can come from either the form or a link
1096        $this->section = $request->getVal( 'wpSection', $request->getVal( 'section', '' ) );
1097
1098        if ( $this->section !== null && $this->section !== '' && !$this->isSectionEditSupported() ) {
1099            throw new ErrorPageError( 'sectioneditnotsupported-title', 'sectioneditnotsupported-text' );
1100        }
1101
1102        $this->isNew = !$this->mTitle->exists() || $this->section === 'new';
1103
1104        if ( $request->wasPosted() ) {
1105            $this->importFormDataPosted( $request );
1106        } else {
1107            # Not a posted form? Start with nothing.
1108            wfDebug( __METHOD__ . ": Not a posted form." );
1109            $this->textbox1 = '';
1110            $this->summary = '';
1111            $this->sectiontitle = null;
1112            $this->edittime = '';
1113            $this->editRevId = null;
1114            $this->starttime = wfTimestampNow();
1115            $this->edit = false;
1116            $this->preview = false;
1117            $this->save = false;
1118            $this->diff = false;
1119            $this->minoredit = false;
1120            // Watch may be overridden by request parameters
1121            $this->watchthis = $request->getBool( 'watchthis', false );
1122            if ( $this->watchlistExpiryEnabled ) {
1123                $this->watchlistExpiry = null;
1124            }
1125            $this->recreate = false;
1126
1127            // When creating a new section, we can preload a section title by passing it as the
1128            // preloadtitle parameter in the URL (T15100)
1129            if ( $this->section === 'new' && $request->getCheck( 'preloadtitle' ) ) {
1130                $this->sectiontitle = $request->getVal( 'preloadtitle' );
1131                $this->setNewSectionSummary();
1132            } elseif ( $this->section !== 'new' && $request->getVal( 'summary' ) !== '' ) {
1133                $this->summary = $request->getText( 'summary' );
1134                if ( $this->summary !== '' ) {
1135                    // If a summary has been preset using &summary= we don't want to prompt for
1136                    // a different summary. Only prompt for a summary if the summary is blanked.
1137                    // (T19416)
1138                    $this->autoSumm = md5( '' );
1139                }
1140            }
1141
1142            if ( $request->getVal( 'minor' ) ) {
1143                $this->minoredit = true;
1144            }
1145        }
1146
1147        $this->oldid = $request->getInt( 'oldid' );
1148        $this->parentRevId = $request->getInt( 'parentRevId' );
1149
1150        $this->markAsBot = $request->getBool( 'bot', true );
1151        $this->nosummary = $request->getBool( 'nosummary' );
1152
1153        // May be overridden by revision.
1154        $this->contentModel = $request->getText( 'model', $this->contentModel );
1155        // May be overridden by revision.
1156        $this->contentFormat = $request->getText( 'format', $this->contentFormat );
1157
1158        try {
1159            $handler = $this->contentHandlerFactory->getContentHandler( $this->contentModel );
1160        } catch ( MWUnknownContentModelException $e ) {
1161            throw new ErrorPageError(
1162                'editpage-invalidcontentmodel-title',
1163                'editpage-invalidcontentmodel-text',
1164                [ wfEscapeWikiText( $this->contentModel ) ]
1165            );
1166        }
1167
1168        if ( !$handler->isSupportedFormat( $this->contentFormat ) ) {
1169            throw new ErrorPageError(
1170                'editpage-notsupportedcontentformat-title',
1171                'editpage-notsupportedcontentformat-text',
1172                [
1173                    wfEscapeWikiText( $this->contentFormat ),
1174                    wfEscapeWikiText( ContentHandler::getLocalizedName( $this->contentModel ) )
1175                ]
1176            );
1177        }
1178
1179        // Allow extensions to modify form data
1180        $this->getHookRunner()->onEditPage__importFormData( $this, $request );
1181    }
1182
1183    /**
1184     * @param WebRequest $request
1185     */
1186    private function importFormDataPosted( WebRequest $request ): void {
1187        # These fields need to be checked for encoding.
1188        # Also remove trailing whitespace, but don't remove _initial_
1189        # whitespace from the text boxes. This may be significant formatting.
1190        $this->textbox1 = rtrim( $request->getText( 'wpTextbox1' ) );
1191        if ( !$request->getCheck( 'wpTextbox2' ) ) {
1192            // Skip this if wpTextbox2 has input, it indicates that we came
1193            // from a conflict page with raw page text, not a custom form
1194            // modified by subclasses
1195            $textbox1 = $this->importContentFormData( $request );
1196            if ( $textbox1 !== null ) {
1197                $this->textbox1 = $textbox1;
1198            }
1199        }
1200
1201        $this->unicodeCheck = $request->getText( 'wpUnicodeCheck' );
1202
1203        if ( $this->section === 'new' ) {
1204            # Allow setting sectiontitle different from the edit summary.
1205            # Note that wpSectionTitle is not yet a part of the actual edit form, as wpSummary is
1206            # currently doing double duty as both edit summary and section title. Right now this
1207            # is just to allow API edits to work around this limitation, but this should be
1208            # incorporated into the actual edit form when EditPage is rewritten (T20654, T28312).
1209            if ( $request->getCheck( 'wpSectionTitle' ) ) {
1210                $this->sectiontitle = $request->getText( 'wpSectionTitle' );
1211                if ( $request->getCheck( 'wpSummary' ) ) {
1212                    $this->summary = $request->getText( 'wpSummary' );
1213                }
1214            } else {
1215                $this->sectiontitle = $request->getText( 'wpSummary' );
1216            }
1217        } else {
1218            $this->sectiontitle = null;
1219            $this->summary = $request->getText( 'wpSummary' );
1220        }
1221
1222        # If the summary consists of a heading, e.g. '==Foobar==', extract the title from the
1223        # header syntax, e.g. 'Foobar'. This is mainly an issue when we are using wpSummary for
1224        # section titles. (T3600)
1225        # It is weird to modify 'sectiontitle', even when it is provided when using the API, but API
1226        # users have come to rely on it: https://github.com/wikimedia-gadgets/twinkle/issues/1625
1227        $this->summary = preg_replace( '/^\s*=+\s*(.*?)\s*=+\s*$/', '$1', $this->summary );
1228        if ( $this->sectiontitle !== null ) {
1229            $this->sectiontitle = preg_replace( '/^\s*=+\s*(.*?)\s*=+\s*$/', '$1', $this->sectiontitle );
1230        }
1231
1232        // @phan-suppress-next-line PhanSuspiciousValueComparison
1233        if ( $this->section === 'new' ) {
1234            $this->setNewSectionSummary();
1235        }
1236
1237        $this->edittime = $request->getVal( 'wpEdittime' );
1238        $this->editRevId = $request->getIntOrNull( 'editRevId' );
1239        $this->starttime = $request->getVal( 'wpStarttime' );
1240
1241        $undidRev = $request->getInt( 'wpUndidRevision' );
1242        if ( $undidRev ) {
1243            $this->undidRev = $undidRev;
1244        }
1245        $undoAfter = $request->getInt( 'wpUndoAfter' );
1246        if ( $undoAfter ) {
1247            $this->undoAfter = $undoAfter;
1248        }
1249
1250        $this->scrolltop = $request->getIntOrNull( 'wpScrolltop' );
1251
1252        if ( $this->textbox1 === '' && !$request->getCheck( 'wpTextbox1' ) ) {
1253            // wpTextbox1 field is missing, possibly due to being "too big"
1254            // according to some filter rules that may have been configured
1255            // for security reasons.
1256            $this->incompleteForm = true;
1257        } else {
1258            // If we receive the last parameter of the request, we can fairly
1259            // claim the POST request has not been truncated.
1260            $this->incompleteForm = !$request->getVal( 'wpUltimateParam' );
1261        }
1262        if ( $this->incompleteForm ) {
1263            # If the form is incomplete, force to preview.
1264            wfDebug( __METHOD__ . ": Form data appears to be incomplete" );
1265            wfDebug( "POST DATA: " . var_export( $request->getPostValues(), true ) );
1266            $this->preview = true;
1267        } else {
1268            $this->preview = $request->getCheck( 'wpPreview' );
1269            $this->diff = $request->getCheck( 'wpDiff' );
1270
1271            // Remember whether a save was requested, so we can indicate
1272            // if we forced preview due to session failure.
1273            $this->mTriedSave = !$this->preview;
1274
1275            if ( $this->tokenOk( $request ) ) {
1276                # Some browsers will not report any submit button
1277                # if the user hits enter in the comment box.
1278                # The unmarked state will be assumed to be a save,
1279                # if the form seems otherwise complete.
1280                wfDebug( __METHOD__ . ": Passed token check." );
1281            } elseif ( $this->diff ) {
1282                # Failed token check, but only requested "Show Changes".
1283                wfDebug( __METHOD__ . ": Failed token check; Show Changes requested." );
1284            } else {
1285                # Page might be a hack attempt posted from
1286                # an external site. Preview instead of saving.
1287                wfDebug( __METHOD__ . ": Failed token check; forcing preview" );
1288                $this->preview = true;
1289            }
1290        }
1291        $this->save = !$this->preview && !$this->diff;
1292        if ( !$this->edittime || !preg_match( '/^\d{14}$/', $this->edittime ) ) {
1293            $this->edittime = null;
1294        }
1295
1296        if ( !$this->starttime || !preg_match( '/^\d{14}$/', $this->starttime ) ) {
1297            $this->starttime = null;
1298        }
1299
1300        $this->recreate = $request->getCheck( 'wpRecreate' );
1301
1302        $user = $this->context->getUser();
1303
1304        $this->minoredit = $request->getCheck( 'wpMinoredit' );
1305        $this->watchthis = $request->getCheck( 'wpWatchthis' );
1306        $expiry = $request->getText( 'wpWatchlistExpiry' );
1307        if ( $this->watchlistExpiryEnabled && $expiry !== '' ) {
1308            // This parsing of the user-posted expiry is done for both preview and saving. This
1309            // is necessary because ApiEditPage uses preview when it saves (yuck!). Note that it
1310            // only works because the unnormalized value is retrieved again below in
1311            // getCheckboxesDefinitionForWatchlist().
1312            $expiry = ExpiryDef::normalizeExpiry( $expiry, TS_ISO_8601 );
1313            if ( $expiry !== false ) {
1314                $this->watchlistExpiry = $expiry;
1315            }
1316        }
1317
1318        # Don't force edit summaries when a user is editing their own user or talk page
1319        if ( ( $this->mTitle->getNamespace() === NS_USER || $this->mTitle->getNamespace() === NS_USER_TALK )
1320            && $this->mTitle->getText() === $user->getName()
1321        ) {
1322            $this->allowBlankSummary = true;
1323        } else {
1324            $this->allowBlankSummary = $request->getBool( 'wpIgnoreBlankSummary' )
1325                || !$this->userOptionsLookup->getOption( $user, 'forceeditsummary' );
1326        }
1327
1328        $this->autoSumm = $request->getText( 'wpAutoSummary' );
1329
1330        $this->allowBlankArticle = $request->getBool( 'wpIgnoreBlankArticle' );
1331        $this->allowSelfRedirect = $request->getBool( 'wpIgnoreSelfRedirect' );
1332
1333        $changeTags = $request->getVal( 'wpChangeTags' );
1334        if ( $changeTags === null || $changeTags === '' ) {
1335            $this->changeTags = [];
1336        } else {
1337            $this->changeTags = array_filter(
1338                array_map(
1339                    'trim',
1340                    explode( ',', $changeTags )
1341                )
1342            );
1343        }
1344    }
1345
1346    /**
1347     * Subpage overridable method for extracting the page content data from the
1348     * posted form to be placed in $this->textbox1, if using customized input
1349     * this method should be overridden and return the page text that will be used
1350     * for saving, preview parsing and so on...
1351     *
1352     * @param WebRequest &$request
1353     * @return string|null
1354     */
1355    protected function importContentFormData( &$request ) {
1356        return null; // Don't do anything, EditPage already extracted wpTextbox1
1357    }
1358
1359    /**
1360     * Initialise form fields in the object
1361     * Called on the first invocation, e.g. when a user clicks an edit link
1362     * @return bool If the requested section is valid
1363     */
1364    private function initialiseForm(): bool {
1365        $this->edittime = $this->page->getTimestamp();
1366        $this->editRevId = $this->page->getLatest();
1367
1368        $dummy = $this->contentHandlerFactory
1369            ->getContentHandler( $this->contentModel )
1370            ->makeEmptyContent();
1371        $content = $this->getContentObject( $dummy ); # TODO: track content object?!
1372        if ( $content === $dummy ) { // Invalid section
1373            $this->noSuchSectionPage();
1374            return false;
1375        }
1376
1377        if ( !$content ) {
1378            $out = $this->context->getOutput();
1379            $this->editFormPageTop .= Html::errorBox(
1380                $out->parseAsInterface( $this->context->msg( 'missing-revision-content',
1381                    $this->oldid,
1382                    Message::plaintextParam( $this->mTitle->getPrefixedText() )
1383                ) )
1384            );
1385        } elseif ( !$this->isSupportedContentModel( $content->getModel() ) ) {
1386            $modelMsg = $this->getContext()->msg( 'content-model-' . $content->getModel() );
1387            $modelName = $modelMsg->exists() ? $modelMsg->text() : $content->getModel();
1388
1389            $out = $this->context->getOutput();
1390            $out->showErrorPage(
1391                'modeleditnotsupported-title',
1392                'modeleditnotsupported-text',
1393                [ $modelName ]
1394            );
1395            return false;
1396        }
1397
1398        $this->textbox1 = $this->toEditText( $content );
1399
1400        $user = $this->context->getUser();
1401        // activate checkboxes if user wants them to be always active
1402        # Sort out the "watch" checkbox
1403        if ( $this->userOptionsLookup->getOption( $user, 'watchdefault' ) ) {
1404            # Watch all edits
1405            $this->watchthis = true;
1406        } elseif ( $this->userOptionsLookup->getOption( $user, 'watchcreations' ) && !$this->mTitle->exists() ) {
1407            # Watch creations
1408            $this->watchthis = true;
1409        } elseif ( $this->watchlistManager->isWatched( $user, $this->mTitle ) ) {
1410            # Already watched
1411            $this->watchthis = true;
1412        }
1413        if ( $this->watchthis && $this->watchlistExpiryEnabled ) {
1414            $watchedItem = $this->watchedItemStore->getWatchedItem( $user, $this->getTitle() );
1415            $this->watchlistExpiry = $watchedItem ? $watchedItem->getExpiry() : null;
1416        }
1417        if ( !$this->isNew && $this->userOptionsLookup->getOption( $user, 'minordefault' ) ) {
1418            $this->minoredit = true;
1419        }
1420        if ( $this->textbox1 === false ) {
1421            return false;
1422        }
1423        return true;
1424    }
1425
1426    /**
1427     * @param Content|null $defaultContent The default value to return
1428     * @return Content|false|null Content on success, $defaultContent for invalid sections
1429     * @since 1.21
1430     */
1431    protected function getContentObject( $defaultContent = null ) {
1432        $services = MediaWikiServices::getInstance();
1433        $request = $this->context->getRequest();
1434
1435        $content = false;
1436
1437        // For non-existent articles and new sections, use preload text if any.
1438        if ( !$this->mTitle->exists() || $this->section === 'new' ) {
1439            $content = $services->getPreloadedContentBuilder()->getPreloadedContent(
1440                $this->mTitle->toPageIdentity(),
1441                $this->context->getUser(),
1442                $request->getVal( 'preload' ),
1443                $request->getArray( 'preloadparams', [] ),
1444                $request->getVal( 'section' )
1445            );
1446        // For existing pages, get text based on "undo" or section parameters.
1447        } elseif ( $this->section !== '' ) {
1448            // Get section edit text (returns $def_text for invalid sections)
1449            $orig = $this->getOriginalContent( $this->getAuthority() );
1450            $content = $orig ? $orig->getSection( $this->section ) : null;
1451
1452            if ( !$content ) {
1453                $content = $defaultContent;
1454            }
1455        } else {
1456            $undoafter = $request->getInt( 'undoafter' );
1457            $undo = $request->getInt( 'undo' );
1458
1459            if ( $undo > 0 && $undoafter > 0 ) {
1460                // The use of getRevisionByTitle() is intentional, as allowing access to
1461                // arbitrary revisions on arbitrary pages bypass partial visibility restrictions (T297322).
1462                $undorev = $this->revisionStore->getRevisionByTitle( $this->mTitle, $undo );
1463                $oldrev = $this->revisionStore->getRevisionByTitle( $this->mTitle, $undoafter );
1464                $undoMsg = null;
1465
1466                # Make sure it's the right page,
1467                # the revisions exist and they were not deleted.
1468                # Otherwise, $content will be left as-is.
1469                if ( $undorev !== null && $oldrev !== null &&
1470                    !$undorev->isDeleted( RevisionRecord::DELETED_TEXT ) &&
1471                    !$oldrev->isDeleted( RevisionRecord::DELETED_TEXT )
1472                ) {
1473                    if ( WikiPage::hasDifferencesOutsideMainSlot( $undorev, $oldrev )
1474                        || !$this->isSupportedContentModel(
1475                            $oldrev->getSlot( SlotRecord::MAIN, RevisionRecord::RAW )->getModel()
1476                        )
1477                    ) {
1478                        // Hack for undo while EditPage can't handle multi-slot editing
1479                        $this->context->getOutput()->redirect( $this->mTitle->getFullURL( [
1480                            'action' => 'mcrundo',
1481                            'undo' => $undo,
1482                            'undoafter' => $undoafter,
1483                        ] ) );
1484                        return false;
1485                    } else {
1486                        $content = $this->getUndoContent( $undorev, $oldrev, $undoMsg );
1487                    }
1488
1489                    if ( $undoMsg === null ) {
1490                        $oldContent = $this->page->getContent( RevisionRecord::RAW );
1491                        $parserOptions = ParserOptions::newFromUserAndLang(
1492                            $this->getUserForPreview(),
1493                            $services->getContentLanguage()
1494                        );
1495                        $contentTransformer = $services->getContentTransformer();
1496                        $newContent = $contentTransformer->preSaveTransform(
1497                            $content, $this->mTitle, $this->getUserForPreview(), $parserOptions
1498                        );
1499
1500                        if ( $newContent->getModel() !== $oldContent->getModel() ) {
1501                            // The undo may change content
1502                            // model if its reverting the top
1503                            // edit. This can result in
1504                            // mismatched content model/format.
1505                            $this->contentModel = $newContent->getModel();
1506                            $oldMainSlot = $oldrev->getSlot(
1507                                SlotRecord::MAIN,
1508                                RevisionRecord::RAW
1509                            );
1510                            $this->contentFormat = $oldMainSlot->getFormat();
1511                            if ( $this->contentFormat === null ) {
1512                                $this->contentFormat = $this->contentHandlerFactory
1513                                    ->getContentHandler( $oldMainSlot->getModel() )
1514                                    ->getDefaultFormat();
1515                            }
1516                        }
1517
1518                        if ( $newContent->equals( $oldContent ) ) {
1519                            # Tell the user that the undo results in no change,
1520                            # i.e. the revisions were already undone.
1521                            $undoMsg = 'nochange';
1522                            $content = false;
1523                        } else {
1524                            # Inform the user of our success and set an automatic edit summary
1525                            $undoMsg = 'success';
1526                            $this->generateUndoEditSummary( $oldrev, $undo, $undorev, $services );
1527                            $this->undidRev = $undo;
1528                            $this->undoAfter = $undoafter;
1529                            $this->formtype = 'diff';
1530                        }
1531                    }
1532                } else {
1533                    // Failed basic checks.
1534                    // Older revisions may have been removed since the link
1535                    // was created, or we may simply have got bogus input.
1536                    $undoMsg = 'norev';
1537                }
1538
1539                $out = $this->context->getOutput();
1540                // Messages: undo-success, undo-failure, undo-main-slot-only, undo-norev,
1541                // undo-nochange.
1542                $class = ( $undoMsg === 'success' ? '' : 'error ' ) . "mw-undo-{$undoMsg}";
1543                $this->editFormPageTop .= Html::rawElement(
1544                    'div',
1545                    [ 'class' => $class ],
1546                    $out->parseAsInterface(
1547                        $this->context->msg( 'undo-' . $undoMsg )->plain()
1548                    )
1549                );
1550            }
1551
1552            if ( $content === false ) {
1553                $content = $this->getOriginalContent( $this->getAuthority() );
1554            }
1555        }
1556
1557        return $content;
1558    }
1559
1560    /**
1561     * When using the "undo" action, generate a default edit summary and save it
1562     * to $this->summary
1563     *
1564     * @param RevisionRecord|null $oldrev The revision in the URI "undoafter" field
1565     * @param int $undo The integer in the URI "undo" field
1566     * @param RevisionRecord|null $undorev The revision in the URI "undo" field
1567     * @param MediaWikiServices $services Service container
1568     * @return void
1569     */
1570    private function generateUndoEditSummary( ?RevisionRecord $oldrev, int $undo,
1571        ?RevisionRecord $undorev, MediaWikiServices $services
1572    ) {
1573        // If we just undid one rev, use an autosummary
1574        $firstrev = $this->revisionStore->getNextRevision( $oldrev );
1575        if ( $firstrev && $firstrev->getId() == $undo ) {
1576            $userText = $undorev->getUser() ?
1577                $undorev->getUser()->getName() :
1578                '';
1579            if ( $userText === '' ) {
1580                $undoSummary = $this->context->msg(
1581                    'undo-summary-username-hidden',
1582                    $undo
1583                )->inContentLanguage()->text();
1584            // Handle external users (imported revisions)
1585            } elseif ( ExternalUserNames::isExternal( $userText ) ) {
1586                $userLinkTitle = ExternalUserNames::getUserLinkTitle( $userText );
1587                if ( $userLinkTitle ) {
1588                    $userLink = $userLinkTitle->getPrefixedText();
1589                    $undoSummary = $this->context->msg(
1590                        'undo-summary-import',
1591                        $undo,
1592                        $userLink,
1593                        $userText
1594                    )->inContentLanguage()->text();
1595                } else {
1596                    $undoSummary = $this->context->msg(
1597                        'undo-summary-import2',
1598                        $undo,
1599                        $userText
1600                    )->inContentLanguage()->text();
1601                }
1602            } else {
1603                $undoIsAnon =
1604                    !$undorev->getUser() ||
1605                    !$undorev->getUser()->isRegistered();
1606                $disableAnonTalk = $services->getMainConfig()->get( MainConfigNames::DisableAnonTalk );
1607                $undoMessage = ( $undoIsAnon && $disableAnonTalk ) ?
1608                    'undo-summary-anon' :
1609                    'undo-summary';
1610                $undoSummary = $this->context->msg(
1611                    $undoMessage,
1612                    $undo,
1613                    $userText
1614                )->inContentLanguage()->text();
1615            }
1616            if ( $this->summary === '' ) {
1617                $this->summary = $undoSummary;
1618            } else {
1619                $this->summary = $undoSummary . $this->context->msg( 'colon-separator' )
1620                    ->inContentLanguage()->text() . $this->summary;
1621            }
1622        }
1623    }
1624
1625    /**
1626     * Returns the result of a three-way merge when undoing changes.
1627     *
1628     * @param RevisionRecord $undoRev Newest revision being undone. Corresponds to `undo`
1629     *        URL parameter.
1630     * @param RevisionRecord $oldRev Revision that is being restored. Corresponds to
1631     *        `undoafter` URL parameter.
1632     * @param ?string &$error If false is returned, this will be set to "norev"
1633     *   if the revision failed to load, or "failure" if the content handler
1634     *   failed to merge the required changes.
1635     *
1636     * @return Content|false
1637     */
1638    private function getUndoContent( RevisionRecord $undoRev, RevisionRecord $oldRev, &$error ) {
1639        $handler = $this->contentHandlerFactory
1640            ->getContentHandler( $undoRev->getSlot(
1641                SlotRecord::MAIN,
1642                RevisionRecord::RAW
1643            )->getModel() );
1644        $currentContent = $this->page->getRevisionRecord()
1645            ->getContent( SlotRecord::MAIN );
1646        $undoContent = $undoRev->getContent( SlotRecord::MAIN );
1647        $undoAfterContent = $oldRev->getContent( SlotRecord::MAIN );
1648        $undoIsLatest = $this->page->getRevisionRecord()->getId() === $undoRev->getId();
1649        if ( $currentContent === null
1650            || $undoContent === null
1651            || $undoAfterContent === null
1652        ) {
1653            $error = 'norev';
1654            return false;
1655        }
1656
1657        $content = $handler->getUndoContent(
1658            $currentContent,
1659            $undoContent,
1660            $undoAfterContent,
1661            $undoIsLatest
1662        );
1663        if ( $content === false ) {
1664            $error = 'failure';
1665        }
1666        return $content;
1667    }
1668
1669    /**
1670     * Get the content of the wanted revision, without section extraction.
1671     *
1672     * The result of this function can be used to compare user's input with
1673     * section replaced in its context (using WikiPage::replaceSectionAtRev())
1674     * to the original text of the edit.
1675     *
1676     * This differs from Article::getContent() that when a missing revision is
1677     * encountered the result will be null and not the
1678     * 'missing-revision' message.
1679     *
1680     * @param Authority $performer to get the revision for
1681     * @return Content|null
1682     */
1683    private function getOriginalContent( Authority $performer ): ?Content {
1684        if ( $this->section === 'new' ) {
1685            return $this->getCurrentContent();
1686        }
1687        $revRecord = $this->mArticle->fetchRevisionRecord();
1688        if ( $revRecord === null ) {
1689            return $this->contentHandlerFactory
1690                ->getContentHandler( $this->contentModel )
1691                ->makeEmptyContent();
1692        }
1693        return $revRecord->getContent( SlotRecord::MAIN, RevisionRecord::FOR_THIS_USER, $performer );
1694    }
1695
1696    /**
1697     * Get the edit's parent revision ID
1698     *
1699     * The "parent" revision is the ancestor that should be recorded in this
1700     * page's revision history.  It is either the revision ID of the in-memory
1701     * article content, or in the case of a 3-way merge in order to rebase
1702     * across a recoverable edit conflict, the ID of the newer revision to
1703     * which we have rebased this page.
1704     *
1705     * @return int Revision ID
1706     */
1707    private function getParentRevId() {
1708        if ( $this->parentRevId ) {
1709            return $this->parentRevId;
1710        } else {
1711            return $this->mArticle->getRevIdFetched();
1712        }
1713    }
1714
1715    /**
1716     * Get the current content of the page. This is basically similar to
1717     * WikiPage::getContent( RevisionRecord::RAW ) except that when the page doesn't
1718     * exist an empty content object is returned instead of null.
1719     *
1720     * @since 1.21
1721     * @return Content
1722     */
1723    protected function getCurrentContent() {
1724        $revRecord = $this->page->getRevisionRecord();
1725        $content = $revRecord ? $revRecord->getContent(
1726            SlotRecord::MAIN,
1727            RevisionRecord::RAW
1728        ) : null;
1729
1730        if ( $content === null ) {
1731            return $this->contentHandlerFactory
1732                ->getContentHandler( $this->contentModel )
1733                ->makeEmptyContent();
1734        }
1735
1736        return $content;
1737    }
1738
1739    /**
1740     * Make sure the form isn't faking a user's credentials.
1741     *
1742     * @param WebRequest $request
1743     * @return bool
1744     */
1745    private function tokenOk( WebRequest $request ): bool {
1746        $token = $request->getVal( 'wpEditToken' );
1747        $user = $this->context->getUser();
1748        $this->mTokenOk = $user->matchEditToken( $token );
1749        return $this->mTokenOk;
1750    }
1751
1752    /**
1753     * Sets post-edit cookie indicating the user just saved a particular revision.
1754     *
1755     * This uses a temporary cookie for each revision ID so separate saves will never
1756     * interfere with each other.
1757     *
1758     * Article::view deletes the cookie on server-side after the redirect and
1759     * converts the value to the global JavaScript variable wgPostEdit.
1760     *
1761     * If the variable were set on the server, it would be cached, which is unwanted
1762     * since the post-edit state should only apply to the load right after the save.
1763     *
1764     * @param int $statusValue The status value (to check for new article status)
1765     */
1766    private function setPostEditCookie( int $statusValue ): void {
1767        $revisionId = $this->page->getLatest();
1768        $postEditKey = self::POST_EDIT_COOKIE_KEY_PREFIX . $revisionId;
1769
1770        $val = 'saved';
1771        if ( $statusValue === self::AS_SUCCESS_NEW_ARTICLE ) {
1772            $val = 'created';
1773        } elseif ( $this->oldid ) {
1774            $val = 'restored';
1775        }
1776        if ( $this->tempUserCreateDone ) {
1777            $val .= '+tempuser';
1778        }
1779
1780        $response = $this->context->getRequest()->response();
1781        $response->setCookie( $postEditKey, $val, time() + self::POST_EDIT_COOKIE_DURATION );
1782    }
1783
1784    /**
1785     * Attempt submission
1786     * @param array|false &$resultDetails See docs for $result in internalAttemptSavePrivate @phan-output-reference
1787     * @throws UserBlockedError|ReadOnlyError|ThrottledError|PermissionsError
1788     * @return Status
1789     */
1790    public function attemptSave( &$resultDetails = false ) {
1791        // Allow bots to exempt some edits from bot flagging
1792        $markAsBot = $this->markAsBot
1793            && $this->getAuthority()->isAllowed( 'bot' );
1794
1795        // Allow trusted users to mark some edits as minor
1796        $markAsMinor = $this->minoredit && !$this->isNew
1797            && $this->getAuthority()->isAllowed( 'minoredit' );
1798
1799        $status = $this->internalAttemptSavePrivate( $resultDetails, $markAsBot, $markAsMinor );
1800
1801        $this->getHookRunner()->onEditPage__attemptSave_after( $this, $status, $resultDetails );
1802
1803        return $status;
1804    }
1805
1806    /**
1807     * Log when a page was successfully saved after the edit conflict view
1808     */
1809    private function incrementResolvedConflicts(): void {
1810        if ( $this->context->getRequest()->getText( 'mode' ) !== 'conflict' ) {
1811            return;
1812        }
1813
1814        $this->getEditConflictHelper()->incrementResolvedStats( $this->context->getUser() );
1815    }
1816
1817    /**
1818     * Handle status, such as after attempt save
1819     *
1820     * @param Status $status
1821     * @param array|false $resultDetails
1822     *
1823     * @throws ErrorPageError
1824     * @return bool False, if output is done, true if rest of the form should be displayed
1825     */
1826    private function handleStatus( Status $status, $resultDetails ): bool {
1827        $statusValue = is_int( $status->value ) ? $status->value : 0;
1828
1829        /**
1830         * @todo FIXME: once the interface for internalAttemptSavePrivate() is made
1831         *   nicer, this should use the message in $status
1832         */
1833        if ( $statusValue === self::AS_SUCCESS_UPDATE
1834            || $statusValue === self::AS_SUCCESS_NEW_ARTICLE
1835        ) {
1836            $this->incrementResolvedConflicts();
1837
1838            $this->didSave = true;
1839            if ( !$resultDetails['nullEdit'] ) {
1840                $this->setPostEditCookie( $statusValue );
1841            }
1842        }
1843
1844        $out = $this->context->getOutput();
1845
1846        // "wpExtraQueryRedirect" is a hidden input to modify
1847        // after save URL and is not used by actual edit form
1848        $request = $this->context->getRequest();
1849        $extraQueryRedirect = $request->getVal( 'wpExtraQueryRedirect' );
1850
1851        switch ( $statusValue ) {
1852            case self::AS_HOOK_ERROR_EXPECTED:
1853            case self::AS_CONTENT_TOO_BIG:
1854            case self::AS_ARTICLE_WAS_DELETED:
1855            case self::AS_CONFLICT_DETECTED:
1856            case self::AS_SUMMARY_NEEDED:
1857            case self::AS_TEXTBOX_EMPTY:
1858            case self::AS_MAX_ARTICLE_SIZE_EXCEEDED:
1859            case self::AS_END:
1860            case self::AS_BLANK_ARTICLE:
1861            case self::AS_SELF_REDIRECT:
1862            case self::AS_REVISION_WAS_DELETED:
1863                return true;
1864
1865            case self::AS_HOOK_ERROR:
1866                return false;
1867
1868            case self::AS_PARSE_ERROR:
1869            case self::AS_UNICODE_NOT_SUPPORTED:
1870            case self::AS_UNABLE_TO_ACQUIRE_TEMP_ACCOUNT:
1871                $out->wrapWikiTextAsInterface( 'error',
1872                    $status->getWikiText( false, false, $this->context->getLanguage() )
1873                );
1874                return true;
1875
1876            case self::AS_SUCCESS_NEW_ARTICLE:
1877                $queryParts = [];
1878                if ( $resultDetails['redirect'] ) {
1879                    $queryParts[] = 'redirect=no';
1880                }
1881                if ( $extraQueryRedirect ) {
1882                    $queryParts[] = $extraQueryRedirect;
1883                }
1884                $anchor = $resultDetails['sectionanchor'] ?? '';
1885                $this->doPostEditRedirect( implode( '&', $queryParts ), $anchor );
1886                return false;
1887
1888            case self::AS_SUCCESS_UPDATE:
1889                $extraQuery = '';
1890                $sectionanchor = $resultDetails['sectionanchor'];
1891                // Give extensions a chance to modify URL query on update
1892                $this->getHookRunner()->onArticleUpdateBeforeRedirect( $this->mArticle,
1893                    $sectionanchor, $extraQuery );
1894
1895                $queryParts = [];
1896                if ( $resultDetails['redirect'] ) {
1897                    $queryParts[] = 'redirect=no';
1898                }
1899                if ( $extraQuery ) {
1900                    $queryParts[] = $extraQuery;
1901                }
1902                if ( $extraQueryRedirect ) {
1903                    $queryParts[] = $extraQueryRedirect;
1904                }
1905                $this->doPostEditRedirect( implode( '&', $queryParts ), $sectionanchor );
1906                return false;
1907
1908            case self::AS_SPAM_ERROR:
1909                $this->spamPageWithContent( $resultDetails['spam'] ?? false );
1910                return false;
1911
1912            case self::AS_BLOCKED_PAGE_FOR_USER:
1913                throw new UserBlockedError(
1914                    // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Block is checked and not null
1915                    $this->context->getUser()->getBlock(),
1916                    $this->context->getUser(),
1917                    $this->context->getLanguage(),
1918                    $request->getIP()
1919                );
1920
1921            case self::AS_IMAGE_REDIRECT_ANON:
1922            case self::AS_IMAGE_REDIRECT_LOGGED:
1923                throw new PermissionsError( 'upload' );
1924
1925            case self::AS_READ_ONLY_PAGE_ANON:
1926            case self::AS_READ_ONLY_PAGE_LOGGED:
1927                throw new PermissionsError( 'edit' );
1928
1929            case self::AS_READ_ONLY_PAGE:
1930                throw new ReadOnlyError;
1931
1932            case self::AS_RATE_LIMITED:
1933                $out->wrapWikiTextAsInterface( 'error',
1934                    wfMessage( 'actionthrottledtext' )->plain()
1935                );
1936                return true;
1937
1938            case self::AS_NO_CREATE_PERMISSION:
1939                $permission = $this->mTitle->isTalkPage() ? 'createtalk' : 'createpage';
1940                throw new PermissionsError( $permission );
1941
1942            case self::AS_NO_CHANGE_CONTENT_MODEL:
1943                throw new PermissionsError( 'editcontentmodel' );
1944
1945            default:
1946                // We don't recognize $statusValue. The only way that can happen
1947                // is if an extension hook aborted from inside ArticleSave.
1948                // Render the status object into $this->hookError
1949                // FIXME this sucks, we should just use the Status object throughout
1950                $this->hookError = Html::errorBox(
1951                    "\n" . $status->getWikiText( false, false, $this->context->getLanguage() )
1952                );
1953                return true;
1954        }
1955    }
1956
1957    /**
1958     * Emit the post-save redirect. The URL is modifiable with a hook.
1959     *
1960     * @param string $query
1961     * @param string $anchor
1962     * @return void
1963     */
1964    private function doPostEditRedirect( $query, $anchor ) {
1965        $out = $this->context->getOutput();
1966        $url = $this->mTitle->getFullURL( $query ) . $anchor;
1967        $user = $this->getUserForSave();
1968        // If the temporary account was created in this request,
1969        // or if the temporary account has zero edits (implying
1970        // that the account was created during a failed edit
1971        // attempt in a previous request), perform the top-level
1972        // redirect to ensure the account is attached.
1973        // Note that the temp user could already have performed
1974        // the top-level redirect if this a first edit on
1975        // a wiki that is not the user's home wiki.
1976        $shouldRedirectForTempUser = $this->tempUserCreateDone ||
1977            ( $user->isTemp() && ( $user->getEditCount() === 0 ) );
1978        if ( $shouldRedirectForTempUser ) {
1979            $this->getHookRunner()->onTempUserCreatedRedirect(
1980                $this->context->getRequest()->getSession(),
1981                $user,
1982                $this->mTitle->getPrefixedDBkey(),
1983                $query,
1984                $anchor,
1985                $url
1986            );
1987        }
1988        $out->redirect( $url );
1989    }
1990
1991    /**
1992     * Set the edit summary and link anchor to be used for a new section.
1993     */
1994    private function setNewSectionSummary(): void {
1995        Assert::precondition( $this->section === 'new', 'This method can only be called for new sections' );
1996        Assert::precondition( $this->sectiontitle !== null, 'This method can only be called for new sections' );
1997
1998        $services = MediaWikiServices::getInstance();
1999        $parser = $services->getParser();
2000        $textFormatter = $services->getMessageFormatterFactory()->getTextFormatter(
2001            $services->getContentLanguageCode()->toString()
2002        );
2003
2004        if ( $this->sectiontitle !== '' ) {
2005            $this->newSectionAnchor = $this->guessSectionName( $this->sectiontitle );
2006            // If no edit summary was specified, create one automatically from the section
2007            // title and have it link to the new section. Otherwise, respect the summary as
2008            // passed.
2009            if ( $this->summary === '' ) {
2010                $messageValue = MessageValue::new( 'newsectionsummary' )
2011                    ->plaintextParams( $parser->stripSectionName( $this->sectiontitle ) );
2012                $this->summary = $textFormatter->format( $messageValue );
2013            }
2014        } else {
2015            $this->newSectionAnchor = '';
2016        }
2017    }
2018
2019    /**
2020     * Deprecated public access to attempting save, see documentation on
2021     * internalAttemptSavePrivate()
2022     *
2023     * @deprecated since 1.43
2024     * @param array &$result
2025     * @param bool $markAsBot
2026     * @param bool $markAsMinor
2027     * @return Status
2028     */
2029    public function internalAttemptSave( &$result, $markAsBot = false, $markAsMinor = false ) {
2030        wfDeprecated( __METHOD__, '1.43' );
2031        return $this->internalAttemptSavePrivate( $result, $markAsBot, $markAsMinor );
2032    }
2033
2034    /**
2035     * Attempt submission (no UI)
2036     *
2037     * @param array &$result Array to add statuses to, currently with the
2038     *   possible keys:
2039     *   - spam (string): Spam string from content if any spam is detected by
2040     *     matchSpamRegex.
2041     *   - sectionanchor (string): Section anchor for a section save.
2042     *   - nullEdit (bool): Set if doUserEditContent is OK.  True if null edit,
2043     *     false otherwise.
2044     *   - redirect (bool): Set if doUserEditContent is OK. True if resulting
2045     *     revision is a redirect.
2046     * @param bool $markAsBot True if edit is being made under the bot right
2047     *     and the bot wishes the edit to be marked as such.
2048     * @param bool $markAsMinor True if edit should be marked as minor.
2049     *
2050     * @return Status Status object, possibly with a message, but always with
2051     *   one of the AS_* constants in $status->value,
2052     *
2053     * @todo FIXME: This interface is TERRIBLE, but hard to get rid of due to
2054     *   various error display idiosyncrasies. There are also lots of cases
2055     *   where error metadata is set in the object and retrieved later instead
2056     *   of being returned, e.g. AS_CONTENT_TOO_BIG and
2057     *   AS_BLOCKED_PAGE_FOR_USER. All that stuff needs to be cleaned up some
2058     * time.
2059     */
2060    private function internalAttemptSavePrivate( &$result, $markAsBot = false, $markAsMinor = false ) {
2061        // If an attempt to acquire a temporary name failed, don't attempt to do anything else.
2062        if ( $this->unableToAcquireTempName ) {
2063            $status = Status::newFatal( 'temp-user-unable-to-acquire' );
2064            $status->value = self::AS_UNABLE_TO_ACQUIRE_TEMP_ACCOUNT;
2065            return $status;
2066        }
2067        // Auto-create the temporary account user, if the feature is enabled.
2068        // We create the account before any constraint checks or edit hooks fire, to ensure
2069        // that we have an actor and user account that can be used for any logs generated
2070        // by the edit attempt, and to ensure continuity in the user experience (if a constraint
2071        // denies an edit to a logged-out user, that history should be associated with the
2072        // eventually successful account creation)
2073        $tempAccountStatus = $this->createTempUser();
2074        if ( !$tempAccountStatus->isOK() ) {
2075            return $tempAccountStatus;
2076        }
2077        if ( $tempAccountStatus instanceof CreateStatus ) {
2078            $result['savedTempUser'] = $tempAccountStatus->getUser();
2079        }
2080
2081        $useNPPatrol = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::UseNPPatrol );
2082        $useRCPatrol = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::UseRCPatrol );
2083        if ( !$this->getHookRunner()->onEditPage__attemptSave( $this ) ) {
2084            wfDebug( "Hook 'EditPage::attemptSave' aborted article saving" );
2085            $status = Status::newFatal( 'hookaborted' );
2086            $status->value = self::AS_HOOK_ERROR;
2087            return $status;
2088        }
2089
2090        if ( !$this->getHookRunner()->onEditFilter( $this, $this->textbox1, $this->section,
2091            $this->hookError, $this->summary )
2092        ) {
2093            # Error messages etc. could be handled within the hook...
2094            $status = Status::newFatal( 'hookaborted' );
2095            $status->value = self::AS_HOOK_ERROR;
2096            return $status;
2097        } elseif ( $this->hookError ) {
2098            # ...or the hook could be expecting us to produce an error
2099            $status = Status::newFatal( 'hookaborted' );
2100            $status->value = self::AS_HOOK_ERROR_EXPECTED;
2101            return $status;
2102        }
2103
2104        try {
2105            # Construct Content object
2106            $textbox_content = $this->toEditContent( $this->textbox1 );
2107        } catch ( MWContentSerializationException $ex ) {
2108            $status = Status::newFatal(
2109                'content-failed-to-parse',
2110                $this->contentModel,
2111                $this->contentFormat,
2112                $ex->getMessage()
2113            );
2114            $status->value = self::AS_PARSE_ERROR;
2115            return $status;
2116        }
2117
2118        $this->contentLength = strlen( $this->textbox1 );
2119
2120        $requestUser = $this->context->getUser();
2121        $authority = $this->getAuthority();
2122        $pstUser = $this->getUserForPreview();
2123
2124        $changingContentModel = false;
2125        if ( $this->contentModel !== $this->mTitle->getContentModel() ) {
2126            $changingContentModel = true;
2127            $oldContentModel = $this->mTitle->getContentModel();
2128        }
2129
2130        // BEGINNING OF MIGRATION TO EDITCONSTRAINT SYSTEM (see T157658)
2131        /** @var EditConstraintFactory $constraintFactory */
2132        $constraintFactory = MediaWikiServices::getInstance()->getService( '_EditConstraintFactory' );
2133        $constraintRunner = new EditConstraintRunner();
2134
2135        // UnicodeConstraint: ensure that `$this->unicodeCheck` is the correct unicode
2136        $constraintRunner->addConstraint(
2137            new UnicodeConstraint( $this->unicodeCheck )
2138        );
2139
2140        // SimpleAntiSpamConstraint: ensure that the context request does not have
2141        // `wpAntispam` set
2142        // Use $user since there is no permissions aspect
2143        $constraintRunner->addConstraint(
2144            $constraintFactory->newSimpleAntiSpamConstraint(
2145                $this->context->getRequest()->getText( 'wpAntispam' ),
2146                $requestUser,
2147                $this->mTitle
2148            )
2149        );
2150
2151        // SpamRegexConstraint: ensure that the summary and text don't match the spam regex
2152        $constraintRunner->addConstraint(
2153            $constraintFactory->newSpamRegexConstraint(
2154                $this->summary,
2155                $this->sectiontitle,
2156                $this->textbox1,
2157                $this->context->getRequest()->getIP(),
2158                $this->mTitle
2159            )
2160        );
2161        $constraintRunner->addConstraint(
2162            new ImageRedirectConstraint(
2163                $textbox_content,
2164                $this->mTitle,
2165                $authority
2166            )
2167        );
2168        $constraintRunner->addConstraint(
2169            $constraintFactory->newUserBlockConstraint( $this->mTitle, $requestUser )
2170        );
2171        $constraintRunner->addConstraint(
2172            new ContentModelChangeConstraint(
2173                $authority,
2174                $this->mTitle,
2175                $this->contentModel
2176            )
2177        );
2178
2179        $constraintRunner->addConstraint(
2180            $constraintFactory->newReadOnlyConstraint()
2181        );
2182        $constraintRunner->addConstraint(
2183            $constraintFactory->newUserRateLimitConstraint(
2184                $requestUser->toRateLimitSubject(),
2185                $this->mTitle->getContentModel(),
2186                $this->contentModel
2187            )
2188        );
2189        $constraintRunner->addConstraint(
2190            // Same constraint is used to check size before and after merging the
2191            // edits, which use different failure codes
2192            $constraintFactory->newPageSizeConstraint(
2193                $this->contentLength,
2194                PageSizeConstraint::BEFORE_MERGE
2195            )
2196        );
2197        $constraintRunner->addConstraint(
2198            new ChangeTagsConstraint( $authority, $this->changeTags )
2199        );
2200
2201        // If the article has been deleted while editing, don't save it without
2202        // confirmation
2203        $constraintRunner->addConstraint(
2204            new AccidentalRecreationConstraint(
2205                $this->wasDeletedSinceLastEdit(),
2206                $this->recreate
2207            )
2208        );
2209
2210        // Load the page data from the primary DB. If anything changes in the meantime,
2211        // we detect it by using page_latest like a token in a 1 try compare-and-swap.
2212        $this->page->loadPageData( IDBAccessObject::READ_LATEST );
2213        $new = !$this->page->exists();
2214
2215        // We do this last, as some of the other constraints are more specific
2216        $constraintRunner->addConstraint(
2217            $constraintFactory->newEditRightConstraint( $this->getUserForPermissions(), $this->mTitle, $new )
2218        );
2219
2220        // Check the constraints
2221        if ( !$constraintRunner->checkConstraints() ) {
2222            $failed = $constraintRunner->getFailedConstraint();
2223
2224            // Need to check SpamRegexConstraint here, to avoid needing to pass
2225            // $result by reference again
2226            if ( $failed instanceof SpamRegexConstraint ) {
2227                $result['spam'] = $failed->getMatch();
2228            } else {
2229                $this->handleFailedConstraint( $failed );
2230            }
2231
2232            return Status::wrap( $failed->getLegacyStatus() );
2233        }
2234        // END OF MIGRATION TO EDITCONSTRAINT SYSTEM (continued below)
2235
2236        $flags = EDIT_AUTOSUMMARY |
2237            ( $new ? EDIT_NEW : EDIT_UPDATE ) |
2238            ( $markAsMinor ? EDIT_MINOR : 0 ) |
2239            ( $markAsBot ? EDIT_FORCE_BOT : 0 );
2240
2241        if ( $new ) {
2242            $content = $textbox_content;
2243
2244            $result['sectionanchor'] = '';
2245            if ( $this->section === 'new' ) {
2246                if ( $this->sectiontitle !== null ) {
2247                    // Insert the section title above the content.
2248                    $content = $content->addSectionHeader( $this->sectiontitle );
2249                }
2250                $result['sectionanchor'] = $this->newSectionAnchor;
2251            }
2252
2253            $pageUpdater = $this->page->newPageUpdater( $pstUser )
2254                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable False positive
2255                ->setContent( SlotRecord::MAIN, $content );
2256            $pageUpdater->prepareUpdate( $flags );
2257
2258            // BEGINNING OF MIGRATION TO EDITCONSTRAINT SYSTEM (see T157658)
2259            // Create a new runner to avoid rechecking the prior constraints, use the same factory
2260            $constraintRunner = new EditConstraintRunner();
2261
2262            // Don't save a new page if it's blank or if it's a MediaWiki:
2263            // message with content equivalent to default (allow empty pages
2264            // in this case to disable messages, see T52124)
2265            $constraintRunner->addConstraint(
2266                new DefaultTextConstraint(
2267                    $this->mTitle,
2268                    $this->allowBlankArticle,
2269                    $this->textbox1
2270                )
2271            );
2272
2273            $constraintRunner->addConstraint(
2274                $constraintFactory->newEditFilterMergedContentHookConstraint(
2275                    $content,
2276                    $this->context,
2277                    $this->summary,
2278                    $markAsMinor,
2279                    $this->context->getLanguage(),
2280                    $pstUser
2281                )
2282            );
2283
2284            // Check the constraints
2285            if ( !$constraintRunner->checkConstraints() ) {
2286                $failed = $constraintRunner->getFailedConstraint();
2287                $this->handleFailedConstraint( $failed );
2288                return Status::wrap( $failed->getLegacyStatus() );
2289            }
2290            // END OF MIGRATION TO EDITCONSTRAINT SYSTEM (continued below)
2291        } else { # not $new
2292
2293            # Article exists. Check for edit conflict.
2294
2295            $timestamp = $this->page->getTimestamp();
2296            $latest = $this->page->getLatest();
2297
2298            wfDebug( "timestamp: {$timestamp}, edittime: {$this->edittime}" );
2299            wfDebug( "revision: {$latest}, editRevId: {$this->editRevId}" );
2300
2301            $editConflictLogger = LoggerFactory::getInstance( 'EditConflict' );
2302            // An edit conflict is detected if the current revision is different from the
2303            // revision that was current when editing was initiated on the client.
2304            // This is checked based on the timestamp and revision ID.
2305            // TODO: the timestamp based check can probably go away now.
2306            if ( ( $this->edittime !== null && $this->edittime != $timestamp )
2307                || ( $this->editRevId !== null && $this->editRevId != $latest )
2308            ) {
2309                $this->isConflict = true;
2310                if ( $this->section === 'new' ) {
2311                    if ( $this->page->getUserText() === $requestUser->getName() &&
2312                        $this->page->getComment() === $this->summary
2313                    ) {
2314                        // Probably a duplicate submission of a new comment.
2315                        // This can happen when CDN resends a request after
2316                        // a timeout but the first one actually went through.
2317                        $editConflictLogger->debug(
2318                            'Duplicate new section submission; trigger edit conflict!'
2319                        );
2320                    } else {
2321                        // New comment; suppress conflict.
2322                        $this->isConflict = false;
2323                        $editConflictLogger->debug( 'Conflict suppressed; new section' );
2324                    }
2325                } elseif ( $this->section === ''
2326                    && $this->edittime
2327                    && $this->revisionStore->userWasLastToEdit(
2328                        $this->dbProvider->getPrimaryDatabase(),
2329                        $this->mTitle->getArticleID(),
2330                        $requestUser->getId(),
2331                        $this->edittime
2332                    )
2333                ) {
2334                    # Suppress edit conflict with self, except for section edits where merging is required.
2335                    $editConflictLogger->debug( 'Suppressing edit conflict, same user.' );
2336                    $this->isConflict = false;
2337                }
2338            }
2339
2340            if ( $this->isConflict ) {
2341                $editConflictLogger->debug(
2342                    'Conflict! Getting section {section} for time {editTime}'
2343                    . ' (id {editRevId}, article time {timestamp})',
2344                    [
2345                        'section' => $this->section,
2346                        'editTime' => $this->edittime,
2347                        'editRevId' => $this->editRevId,
2348                        'timestamp' => $timestamp,
2349                    ]
2350                );
2351                // @TODO: replaceSectionAtRev() with base ID (not prior current) for ?oldid=X case
2352                // ...or disable section editing for non-current revisions (not exposed anyway).
2353                if ( $this->editRevId !== null ) {
2354                    $content = $this->page->replaceSectionAtRev(
2355                        $this->section,
2356                        $textbox_content,
2357                        $this->sectiontitle,
2358                        $this->editRevId
2359                    );
2360                } else {
2361                    $content = $this->page->replaceSectionContent(
2362                        $this->section,
2363                        $textbox_content,
2364                        $this->sectiontitle,
2365                        $this->edittime
2366                    );
2367                }
2368            } else {
2369                $editConflictLogger->debug(
2370                    'Getting section {section}',
2371                    [ 'section' => $this->section ]
2372                );
2373                $content = $this->page->replaceSectionAtRev(
2374                    $this->section,
2375                    $textbox_content,
2376                    $this->sectiontitle
2377                );
2378            }
2379
2380            if ( $content === null ) {
2381                $editConflictLogger->debug( 'Activating conflict; section replace failed.' );
2382                $this->isConflict = true;
2383                $content = $textbox_content; // do not try to merge here!
2384            } elseif ( $this->isConflict ) {
2385                // Attempt merge
2386                $mergedChange = $this->mergeChangesIntoContent( $content );
2387                if ( $mergedChange !== false ) {
2388                    // Successful merge! Maybe we should tell the user the good news?
2389                    $content = $mergedChange[0];
2390                    $this->parentRevId = $mergedChange[1];
2391                    $this->isConflict = false;
2392                    $editConflictLogger->debug( 'Suppressing edit conflict, successful merge.' );
2393                } else {
2394                    $this->section = '';
2395                    $this->textbox1 = ( $content instanceof TextContent ) ? $content->getText() : '';
2396                    $editConflictLogger->debug( 'Keeping edit conflict, failed merge.' );
2397                }
2398            }
2399
2400            if ( $this->isConflict ) {
2401                return Status::newGood( self::AS_CONFLICT_DETECTED )->setOK( false );
2402            }
2403
2404            $pageUpdater = $this->page->newPageUpdater( $pstUser )
2405                ->setContent( SlotRecord::MAIN, $content );
2406            $pageUpdater->prepareUpdate( $flags );
2407
2408            // BEGINNING OF MIGRATION TO EDITCONSTRAINT SYSTEM (see T157658)
2409            // Create a new runner to avoid rechecking the prior constraints, use the same factory
2410            $constraintRunner = new EditConstraintRunner();
2411            $constraintRunner->addConstraint(
2412                $constraintFactory->newEditFilterMergedContentHookConstraint(
2413                    $content,
2414                    $this->context,
2415                    $this->summary,
2416                    $markAsMinor,
2417                    $this->context->getLanguage(),
2418                    $pstUser
2419                )
2420            );
2421            $constraintRunner->addConstraint(
2422                new NewSectionMissingSubjectConstraint(
2423                    $this->section,
2424                    $this->sectiontitle ?? '',
2425                    $this->allowBlankSummary
2426                )
2427            );
2428            $constraintRunner->addConstraint(
2429                new MissingCommentConstraint( $this->section, $this->textbox1 )
2430            );
2431            $constraintRunner->addConstraint(
2432                new ExistingSectionEditConstraint(
2433                    $this->section,
2434                    $this->summary,
2435                    $this->autoSumm,
2436                    $this->allowBlankSummary,
2437                    $content,
2438                    $this->getOriginalContent( $authority )
2439                )
2440            );
2441            // Check the constraints
2442            if ( !$constraintRunner->checkConstraints() ) {
2443                $failed = $constraintRunner->getFailedConstraint();
2444                $this->handleFailedConstraint( $failed );
2445                return Status::wrap( $failed->getLegacyStatus() );
2446            }
2447            // END OF MIGRATION TO EDITCONSTRAINT SYSTEM (continued below)
2448
2449            # All's well
2450            $sectionAnchor = '';
2451            if ( $this->section === 'new' ) {
2452                $sectionAnchor = $this->newSectionAnchor;
2453            } elseif ( $this->section !== '' ) {
2454                # Try to get a section anchor from the section source, redirect
2455                # to edited section if header found.
2456                # XXX: Might be better to integrate this into WikiPage::replaceSectionAtRev
2457                # for duplicate heading checking and maybe parsing.
2458                $hasmatch = preg_match( "/^ *([=]{1,6})(.*?)(\\1) *\\n/i", $this->textbox1, $matches );
2459                # We can't deal with anchors, includes, html etc in the header for now,
2460                # headline would need to be parsed to improve this.
2461                if ( $hasmatch && $matches[2] !== '' ) {
2462                    $sectionAnchor = $this->guessSectionName( $matches[2] );
2463                }
2464            }
2465            $result['sectionanchor'] = $sectionAnchor;
2466
2467            // Save errors may fall down to the edit form, but we've now
2468            // merged the section into full text. Clear the section field
2469            // so that later submission of conflict forms won't try to
2470            // replace that into a duplicated mess.
2471            $this->textbox1 = $this->toEditText( $content );
2472            $this->section = '';
2473        }
2474
2475        // Check for length errors again now that the section is merged in
2476        $this->contentLength = strlen( $this->toEditText( $content ) );
2477
2478        // BEGINNING OF MIGRATION TO EDITCONSTRAINT SYSTEM (see T157658)
2479        // Create a new runner to avoid rechecking the prior constraints, use the same factory
2480        $constraintRunner = new EditConstraintRunner();
2481        $constraintRunner->addConstraint(
2482            new SelfRedirectConstraint(
2483                $this->allowSelfRedirect,
2484                $content,
2485                $this->getCurrentContent(),
2486                $this->getTitle()
2487            )
2488        );
2489        $constraintRunner->addConstraint(
2490            // Same constraint is used to check size before and after merging the
2491            // edits, which use different failure codes
2492            $constraintFactory->newPageSizeConstraint(
2493                $this->contentLength,
2494                PageSizeConstraint::AFTER_MERGE
2495            )
2496        );
2497        // Check the constraints
2498        if ( !$constraintRunner->checkConstraints() ) {
2499            $failed = $constraintRunner->getFailedConstraint();
2500            $this->handleFailedConstraint( $failed );
2501            return Status::wrap( $failed->getLegacyStatus() );
2502        }
2503        // END OF MIGRATION TO EDITCONSTRAINT SYSTEM
2504
2505        if ( $this->undidRev && $this->isUndoClean( $content ) ) {
2506            // As the user can change the edit's content before saving, we only mark
2507            // "clean" undos as reverts. This is to avoid abuse by marking irrelevant
2508            // edits as undos.
2509            $pageUpdater
2510                ->setOriginalRevisionId( $this->undoAfter ?: false )
2511                ->markAsRevert(
2512                    EditResult::REVERT_UNDO,
2513                    $this->undidRev,
2514                    $this->undoAfter ?: null
2515                );
2516        }
2517
2518        $needsPatrol = $useRCPatrol || ( $useNPPatrol && !$this->page->exists() );
2519        if ( $needsPatrol && $authority->authorizeWrite( 'autopatrol', $this->getTitle() ) ) {
2520            $pageUpdater->setRcPatrolStatus( RecentChange::PRC_AUTOPATROLLED );
2521        }
2522
2523        $pageUpdater
2524            ->addTags( $this->changeTags )
2525            ->saveRevision(
2526                CommentStoreComment::newUnsavedComment( trim( $this->summary ) ),
2527                $flags
2528            );
2529        $doEditStatus = $pageUpdater->getStatus();
2530
2531        if ( !$doEditStatus->isOK() ) {
2532            // Failure from doEdit()
2533            // Show the edit conflict page for certain recognized errors from doEdit(),
2534            // but don't show it for errors from extension hooks
2535            if (
2536                $doEditStatus->failedBecausePageMissing() ||
2537                $doEditStatus->failedBecausePageExists() ||
2538                $doEditStatus->failedBecauseOfConflict()
2539            ) {
2540                $this->isConflict = true;
2541                // Destroys data doEdit() put in $status->value but who cares
2542                $doEditStatus->value = self::AS_END;
2543            }
2544            return $doEditStatus;
2545        }
2546
2547        $result['nullEdit'] = !$doEditStatus->wasRevisionCreated();
2548        if ( $result['nullEdit'] ) {
2549            // We didn't know if it was a null edit until now, so bump the rate limit now
2550            $limitSubject = $requestUser->toRateLimitSubject();
2551            MediaWikiServices::getInstance()->getRateLimiter()->limit( $limitSubject, 'linkpurge' );
2552        }
2553        $result['redirect'] = $content->isRedirect();
2554
2555        $this->updateWatchlist();
2556
2557        // If the content model changed, add a log entry
2558        if ( $changingContentModel ) {
2559            $this->addContentModelChangeLogEntry(
2560                $this->getUserForSave(),
2561                // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable
2562                // $oldContentModel is set when $changingContentModel is true
2563                $new ? false : $oldContentModel,
2564                $this->contentModel,
2565                $this->summary
2566            );
2567        }
2568
2569        // Instead of carrying the same status object throughout, it is created right
2570        // when it is returned, either at an earlier point due to an error or here
2571        // due to a successful edit.
2572        $statusCode = ( $new ? self::AS_SUCCESS_NEW_ARTICLE : self::AS_SUCCESS_UPDATE );
2573        return Status::newGood( $statusCode );
2574    }
2575
2576    /**
2577     * Apply the specific updates needed for the EditPage fields based on which constraint
2578     * failed, rather than interspersing this logic throughout internalAttemptSavePrivate at
2579     * each of the points the constraints are checked. Eventually, this will act on the
2580     * result from the backend.
2581     *
2582     * @param IEditConstraint $failed
2583     */
2584    private function handleFailedConstraint( IEditConstraint $failed ): void {
2585        if ( $failed instanceof PageSizeConstraint ) {
2586            // Error will be displayed by showEditForm()
2587            $this->tooBig = true;
2588        } elseif ( $failed instanceof UserBlockConstraint ) {
2589            // Auto-block user's IP if the account was "hard" blocked
2590            if ( !MediaWikiServices::getInstance()->getReadOnlyMode()->isReadOnly() ) {
2591                $this->context->getUser()->spreadAnyEditBlock();
2592            }
2593        } elseif ( $failed instanceof DefaultTextConstraint ) {
2594            $this->blankArticle = true;
2595        } elseif ( $failed instanceof EditFilterMergedContentHookConstraint ) {
2596            $this->hookError = $failed->getHookError();
2597        } elseif (
2598            // ExistingSectionEditConstraint also checks for revisions deleted
2599            // since the edit was loaded, which doesn't indicate a missing summary
2600            (
2601                $failed instanceof ExistingSectionEditConstraint
2602                && $failed->getLegacyStatus()->value === self::AS_SUMMARY_NEEDED
2603            ) ||
2604            $failed instanceof NewSectionMissingSubjectConstraint
2605        ) {
2606            $this->missingSummary = true;
2607        } elseif ( $failed instanceof MissingCommentConstraint ) {
2608            $this->missingComment = true;
2609        } elseif ( $failed instanceof SelfRedirectConstraint ) {
2610            $this->selfRedirect = true;
2611        }
2612    }
2613
2614    /**
2615     * Does checks and compares the automatically generated undo content with the
2616     * one that was submitted by the user. If they match, the undo is considered "clean".
2617     * Otherwise there is no guarantee if anything was reverted at all, as the user could
2618     * even swap out entire content.
2619     *
2620     * @param Content $content
2621     *
2622     * @return bool
2623     */
2624    private function isUndoClean( Content $content ): bool {
2625        // Check whether the undo was "clean", that is the user has not modified
2626        // the automatically generated content.
2627        $undoRev = $this->revisionStore->getRevisionById( $this->undidRev );
2628        if ( $undoRev === null ) {
2629            return false;
2630        }
2631
2632        if ( $this->undoAfter ) {
2633            $oldRev = $this->revisionStore->getRevisionById( $this->undoAfter );
2634        } else {
2635            $oldRev = $this->revisionStore->getPreviousRevision( $undoRev );
2636        }
2637
2638        if ( $oldRev === null ||
2639            $undoRev->isDeleted( RevisionRecord::DELETED_TEXT ) ||
2640            $oldRev->isDeleted( RevisionRecord::DELETED_TEXT )
2641        ) {
2642            return false;
2643        }
2644
2645        $undoContent = $this->getUndoContent( $undoRev, $oldRev, $undoError );
2646        if ( !$undoContent ) {
2647            return false;
2648        }
2649
2650        // Do a pre-save transform on the retrieved undo content
2651        $services = MediaWikiServices::getInstance();
2652        $contentLanguage = $services->getContentLanguage();
2653        $user = $this->getUserForPreview();
2654        $parserOptions = ParserOptions::newFromUserAndLang( $user, $contentLanguage );
2655        $contentTransformer = $services->getContentTransformer();
2656        $undoContent = $contentTransformer->preSaveTransform( $undoContent, $this->mTitle, $user, $parserOptions );
2657
2658        if ( $undoContent->equals( $content ) ) {
2659            return true;
2660        }
2661        return false;
2662    }
2663
2664    /**
2665     * @param UserIdentity $user
2666     * @param string|false $oldModel false if the page is being newly created
2667     * @param string $newModel
2668     * @param string $reason
2669     */
2670    private function addContentModelChangeLogEntry( UserIdentity $user, $oldModel, $newModel, $reason = "" ): void {
2671        $new = $oldModel === false;
2672        $log = new ManualLogEntry( 'contentmodel', $new ? 'new' : 'change' );
2673        $log->setPerformer( $user );
2674        $log->setTarget( $this->mTitle );
2675        $log->setComment( is_string( $reason ) ? $reason : "" );
2676        $log->setParameters( [
2677            '4::oldmodel' => $oldModel,
2678            '5::newmodel' => $newModel
2679        ] );
2680        $logid = $log->insert();
2681        $log->publish( $logid );
2682    }
2683
2684    /**
2685     * Register the change of watch status
2686     */
2687    private function updateWatchlist(): void {
2688        if ( $this->tempUserCreateActive ) {
2689            return;
2690        }
2691        $user = $this->getUserForSave();
2692        if ( !$user->isNamed() ) {
2693            return;
2694        }
2695
2696        $title = $this->mTitle;
2697        $watch = $this->watchthis;
2698        $watchlistExpiry = $this->watchlistExpiry;
2699
2700        // This can't run as a DeferredUpdate due to a possible race condition
2701        // when the post-edit redirect happens if the pendingUpdates queue is
2702        // too large to finish in time (T259564)
2703        $this->watchlistManager->setWatch( $watch, $user, $title, $watchlistExpiry );
2704
2705        $this->watchedItemStore->maybeEnqueueWatchlistExpiryJob();
2706    }
2707
2708    /**
2709     * Attempts to do 3-way merge of edit content with a base revision
2710     * and current content, in case of edit conflict, in whichever way appropriate
2711     * for the content type.
2712     *
2713     * @param Content $editContent
2714     *
2715     * @return array|false either `false` or an array of the new Content and the
2716     *   updated parent revision id
2717     */
2718    private function mergeChangesIntoContent( Content $editContent ) {
2719        // This is the revision that was current at the time editing was initiated on the client,
2720        // even if the edit was based on an old revision.
2721        $baseRevRecord = $this->getExpectedParentRevision();
2722        $baseContent = $baseRevRecord ?
2723            $baseRevRecord->getContent( SlotRecord::MAIN ) :
2724            null;
2725
2726        if ( $baseContent === null ) {
2727            return false;
2728        } elseif ( $baseRevRecord->isCurrent() ) {
2729            // Impossible to have a conflict when the user just edited the latest revision. This can
2730            // happen e.g. when $wgDiff3 is badly configured.
2731            return [ $editContent, $baseRevRecord->getId() ];
2732        }
2733
2734        // The current state, we want to merge updates into it
2735        $currentRevisionRecord = $this->revisionStore->getRevisionByTitle(
2736            $this->mTitle,
2737            0,
2738            IDBAccessObject::READ_LATEST
2739        );
2740        $currentContent = $currentRevisionRecord
2741            ? $currentRevisionRecord->getContent( SlotRecord::MAIN )
2742            : null;
2743
2744        if ( $currentContent === null ) {
2745            return false;
2746        }
2747
2748        $mergedContent = $this->contentHandlerFactory
2749            ->getContentHandler( $baseContent->getModel() )
2750            ->merge3( $baseContent, $editContent, $currentContent );
2751
2752        if ( $mergedContent ) {
2753            // Also need to update parentRevId to what we just merged.
2754            return [ $mergedContent, $currentRevisionRecord->getId() ];
2755        }
2756
2757        return false;
2758    }
2759
2760    /**
2761     * Returns the RevisionRecord corresponding to the revision that was current at the time
2762     * editing was initiated on the client even if the edit was based on an old revision
2763     *
2764     * @since 1.35
2765     * @return RevisionRecord|null Current revision when editing was initiated on the client
2766     */
2767    public function getExpectedParentRevision() {
2768        if ( $this->mExpectedParentRevision === false ) {
2769            $revRecord = null;
2770            if ( $this->editRevId ) {
2771                $revRecord = $this->revisionStore->getRevisionById(
2772                    $this->editRevId,
2773                    IDBAccessObject::READ_LATEST
2774                );
2775            } elseif ( $this->edittime ) {
2776                $revRecord = $this->revisionStore->getRevisionByTimestamp(
2777                    $this->getTitle(),
2778                    $this->edittime,
2779                    IDBAccessObject::READ_LATEST
2780                );
2781            }
2782            $this->mExpectedParentRevision = $revRecord;
2783        }
2784        return $this->mExpectedParentRevision;
2785    }
2786
2787    public function setHeaders() {
2788        $out = $this->context->getOutput();
2789
2790        $out->addModules( 'mediawiki.action.edit' );
2791        $out->addModuleStyles( 'mediawiki.action.edit.styles' );
2792        $out->addModuleStyles( 'mediawiki.editfont.styles' );
2793        $out->addModuleStyles( 'mediawiki.interface.helpers.styles' );
2794
2795        $user = $this->context->getUser();
2796
2797        if ( $this->userOptionsLookup->getOption( $user, 'uselivepreview' ) ) {
2798            $out->addModules( 'mediawiki.action.edit.preview' );
2799        }
2800
2801        if ( $this->userOptionsLookup->getOption( $user, 'useeditwarning' ) ) {
2802            $out->addModules( 'mediawiki.action.edit.editWarning' );
2803        }
2804
2805        if ( $this->context->getConfig()->get( MainConfigNames::EnableEditRecovery )
2806            && $this->userOptionsLookup->getOption( $user, 'editrecovery' )
2807        ) {
2808            $wasPosted = $this->getContext()->getRequest()->getMethod() === 'POST';
2809            $out->addJsConfigVars( 'wgEditRecoveryWasPosted', $wasPosted );
2810            $out->addModules( 'mediawiki.editRecovery.edit' );
2811        }
2812
2813        # Enabled article-related sidebar, toplinks, etc.
2814        $out->setArticleRelated( true );
2815
2816        $contextTitle = $this->getContextTitle();
2817        if ( $this->isConflict ) {
2818            $msg = 'editconflict';
2819        } elseif ( $contextTitle->exists() && $this->section != '' ) {
2820            $msg = $this->section === 'new' ? 'editingcomment' : 'editingsection';
2821        } else {
2822            $msg = $contextTitle->exists()
2823                || ( $contextTitle->getNamespace() === NS_MEDIAWIKI
2824                    && $contextTitle->getDefaultMessageText() !== false
2825                )
2826                ? 'editing'
2827                : 'creating';
2828        }
2829
2830        # Use the title defined by DISPLAYTITLE magic word when present
2831        # NOTE: getDisplayTitle() returns HTML while getPrefixedText() returns plain text.
2832        #       Escape ::getPrefixedText() so that we have HTML in all cases,
2833        #       and pass as a "raw" parameter to ::setPageTitleMsg().
2834        $displayTitle = isset( $this->mParserOutput ) ? $this->mParserOutput->getDisplayTitle() : false;
2835        if ( $displayTitle === false ) {
2836            $displayTitle = htmlspecialchars(
2837                $contextTitle->getPrefixedText(), ENT_QUOTES, 'UTF-8', false
2838            );
2839        } else {
2840            $out->setDisplayTitle( $displayTitle );
2841        }
2842
2843        // Enclose the title with an element. This is used on live preview to update the
2844        // preview of the display title.
2845        $displayTitle = Html::rawElement( 'span', [ 'id' => 'firstHeadingTitle' ], $displayTitle );
2846
2847        $out->setPageTitleMsg( $this->context->msg( $msg )->rawParams( $displayTitle ) );
2848
2849        $config = $this->context->getConfig();
2850
2851        # Transmit the name of the message to JavaScript. This was added for live preview.
2852        # Live preview doesn't use this anymore. The variable is still transmitted because
2853        # Edit Recovery and user scripts use it.
2854        $out->addJsConfigVars( [
2855            'wgEditMessage' => $msg,
2856        ] );
2857
2858        // Add whether to use 'save' or 'publish' messages to JavaScript for post-edit, other
2859        // editors, etc.
2860        $out->addJsConfigVars(
2861            'wgEditSubmitButtonLabelPublish',
2862            $config->get( MainConfigNames::EditSubmitButtonLabelPublish )
2863        );
2864    }
2865
2866    /**
2867     * Show all applicable editing introductions
2868     */
2869    private function showIntro(): void {
2870        $services = MediaWikiServices::getInstance();
2871
2872        // Hardcoded list of notices that are suppressable for historical reasons.
2873        // This feature was originally added for LiquidThreads, to avoid showing non-essential messages
2874        // when commenting in a thread, but some messages were included (or excluded) by mistake before
2875        // its implementation was moved to one place, and this list doesn't make a lot of sense.
2876        // TODO: Remove the suppressIntro feature from EditPage, and invent a better way for extensions
2877        // to skip individual intro messages.
2878        $skip = $this->suppressIntro ? [
2879            'editintro',
2880            'code-editing-intro',
2881            'sharedupload-desc-create',
2882            'sharedupload-desc-edit',
2883            'userpage-userdoesnotexist',
2884            'blocked-notice-logextract',
2885            'newarticletext',
2886            'newarticletextanon',
2887            'recreate-moveddeleted-warn',
2888        ] : [];
2889
2890        $messages = $services->getIntroMessageBuilder()->getIntroMessages(
2891            IntroMessageBuilder::MORE_FRAMES,
2892            $skip,
2893            $this->context,
2894            $this->mTitle->toPageIdentity(),
2895            $this->mArticle->fetchRevisionRecord(),
2896            $this->context->getUser(),
2897            $this->context->getRequest()->getVal( 'editintro' ),
2898            wfArrayToCgi(
2899                array_diff_key(
2900                    $this->context->getRequest()->getQueryValues(),
2901                    [ 'title' => true, 'returnto' => true, 'returntoquery' => true ]
2902                )
2903            ),
2904            !$this->firsttime,
2905            $this->section !== '' ? $this->section : null
2906        );
2907
2908        foreach ( $messages as $message ) {
2909            $this->context->getOutput()->addHTML( $message );
2910        }
2911    }
2912
2913    /**
2914     * Gets an editable textual representation of $content.
2915     * The textual representation can be turned by into a Content object by the
2916     * toEditContent() method.
2917     *
2918     * If $content is null or false or a string, $content is returned unchanged.
2919     *
2920     * If the given Content object is not of a type that can be edited using
2921     * the text base EditPage, an exception will be raised. Set
2922     * $this->allowNonTextContent to true to allow editing of non-textual
2923     * content.
2924     *
2925     * @param Content|null|false|string $content
2926     * @return string The editable text form of the content.
2927     *
2928     * @throws MWException If $content is not an instance of TextContent and
2929     *   $this->allowNonTextContent is not true.
2930     */
2931    private function toEditText( $content ) {
2932        if ( $content === null || $content === false ) {
2933            return '';
2934        }
2935        if ( is_string( $content ) ) {
2936            return $content;
2937        }
2938
2939        if ( !$this->isSupportedContentModel( $content->getModel() ) ) {
2940            throw new MWException( 'This content model is not supported: ' . $content->getModel() );
2941        }
2942
2943        return $content->serialize( $this->contentFormat );
2944    }
2945
2946    /**
2947     * Turns the given text into a Content object by unserializing it.
2948     *
2949     * If the resulting Content object is not of a type that can be edited using
2950     * the text base EditPage, an exception will be raised. Set
2951     * $this->allowNonTextContent to true to allow editing of non-textual
2952     * content.
2953     *
2954     * @param string|null|false $text Text to unserialize
2955     * @return Content|false|null The content object created from $text. If $text was false
2956     *   or null, then false or null will be returned instead.
2957     *
2958     * @throws MWException If unserializing the text results in a Content
2959     *   object that is not an instance of TextContent and
2960     *   $this->allowNonTextContent is not true.
2961     */
2962    protected function toEditContent( $text ) {
2963        if ( $text === false || $text === null ) {
2964            return $text;
2965        }
2966
2967        $content = ContentHandler::makeContent( $text, $this->getTitle(),
2968            $this->contentModel, $this->contentFormat );
2969
2970        if ( !$this->isSupportedContentModel( $content->getModel() ) ) {
2971            throw new MWException( 'This content model is not supported: ' . $content->getModel() );
2972        }
2973
2974        return $content;
2975    }
2976
2977    /**
2978     * Send the edit form and related headers to OutputPage
2979     */
2980    public function showEditForm() {
2981        # need to parse the preview early so that we know which templates are used,
2982        # otherwise users with "show preview after edit box" will get a blank list
2983        # we parse this near the beginning so that setHeaders can do the title
2984        # setting work instead of leaving it in getPreviewText
2985        $previewOutput = '';
2986        if ( $this->formtype === 'preview' ) {
2987            $previewOutput = $this->getPreviewText();
2988        }
2989
2990        $out = $this->context->getOutput();
2991
2992        // FlaggedRevs depends on running this hook before adding edit notices in showIntro() (T337637)
2993        $this->getHookRunner()->onEditPage__showEditForm_initial( $this, $out );
2994
2995        $this->setHeaders();
2996
2997        // Show applicable editing introductions
2998        $this->showIntro();
2999
3000        if ( !$this->isConflict &&
3001            $this->section !== '' &&
3002            !$this->isSectionEditSupported()
3003        ) {
3004            // We use $this->section to much before this and getVal('wgSection') directly in other places
3005            // at this point we can't reset $this->section to '' to fallback to non-section editing.
3006            // Someone is welcome to try refactoring though
3007            $out->showErrorPage( 'sectioneditnotsupported-title', 'sectioneditnotsupported-text' );
3008            return;
3009        }
3010
3011        $this->showHeader();
3012
3013        $out->addHTML( $this->editFormPageTop );
3014
3015        $user = $this->context->getUser();
3016        if ( $this->userOptionsLookup->getOption( $user, 'previewontop' ) ) {
3017            $this->displayPreviewArea( $previewOutput, true );
3018        }
3019
3020        $out->addHTML( $this->editFormTextTop );
3021
3022        if ( $this->formtype !== 'save' && $this->wasDeletedSinceLastEdit() ) {
3023            $out->addHTML( Html::errorBox(
3024                $out->msg( 'deletedwhileediting' )->parse(),
3025                '',
3026                'mw-deleted-while-editing'
3027            ) );
3028        }
3029
3030        // @todo add EditForm plugin interface and use it here!
3031        //       search for textarea1 and textarea2, and allow EditForm to override all uses.
3032        $out->addHTML( Html::openElement(
3033            'form',
3034            [
3035                'class' => 'mw-editform',
3036                'id' => self::EDITFORM_ID,
3037                'name' => self::EDITFORM_ID,
3038                'method' => 'post',
3039                'action' => $this->getActionURL( $this->getContextTitle() ),
3040                'enctype' => 'multipart/form-data',
3041                'data-mw-editform-type' => $this->formtype
3042            ]
3043        ) );
3044
3045        // Add a check for Unicode support
3046        $out->addHTML( Html::hidden( 'wpUnicodeCheck', self::UNICODE_CHECK ) );
3047
3048        // Add an empty field to trip up spambots
3049        $out->addHTML(
3050            Html::openElement( 'div', [ 'id' => 'antispam-container', 'style' => 'display: none;' ] )
3051            . Html::rawElement(
3052                'label',
3053                [ 'for' => 'wpAntispam' ],
3054                $this->context->msg( 'simpleantispam-label' )->parse()
3055            )
3056            . Html::element(
3057                'input',
3058                [
3059                    'type' => 'text',
3060                    'name' => 'wpAntispam',
3061                    'id' => 'wpAntispam',
3062                    'value' => ''
3063                ]
3064            )
3065            . Html::closeElement( 'div' )
3066        );
3067
3068        $this->getHookRunner()->onEditPage__showEditForm_fields( $this, $out );
3069
3070        // Put these up at the top to ensure they aren't lost on early form submission
3071        $this->showFormBeforeText();
3072
3073        if ( $this->formtype === 'save' && $this->wasDeletedSinceLastEdit() ) {
3074            $username = $this->lastDelete->actor_name;
3075            $comment = $this->commentStore->getComment( 'log_comment', $this->lastDelete )->text;
3076
3077            // It is better to not parse the comment at all than to have templates expanded in the middle
3078            // TODO: can the label be moved outside of the div so that wrapWikiMsg could be used?
3079            $key = $comment === ''
3080                ? 'confirmrecreate-noreason'
3081                : 'confirmrecreate';
3082            $out->addHTML( Html::rawElement(
3083                'div',
3084                [ 'class' => 'mw-confirm-recreate' ],
3085                $this->context->msg( $key )
3086                    ->params( $username )
3087                    ->plaintextParams( $comment )
3088                    ->parse() .
3089                    Html::rawElement(
3090                        'div',
3091                        [],
3092                        Html::check(
3093                            'wpRecreate',
3094                            false,
3095                            [ 'title' => Linker::titleAttrib( 'recreate' ), 'tabindex' => 1, 'id' => 'wpRecreate' ]
3096                        )
3097                        . "\u{00A0}" .
3098                        Html::label(
3099                            $this->context->msg( 'recreate' )->text(),
3100                            'wpRecreate',
3101                            [ 'title' => Linker::titleAttrib( 'recreate' ) ]
3102                        )
3103                    )
3104            ) );
3105        }
3106
3107        # When the summary is hidden, also hide them on preview/show changes
3108        if ( $this->nosummary ) {
3109            $out->addHTML( Html::hidden( 'nosummary', true ) );
3110        }
3111
3112        # If a blank edit summary was previously provided, and the appropriate
3113        # user preference is active, pass a hidden tag as wpIgnoreBlankSummary. This will stop the
3114        # user being bounced back more than once in the event that a summary
3115        # is not required.
3116        # ####
3117        # For a bit more sophisticated detection of blank summaries, hash the
3118        # automatic one and pass that in the hidden field wpAutoSummary.
3119        if (
3120            $this->missingSummary ||
3121            // @phan-suppress-next-line PhanSuspiciousValueComparison
3122            ( $this->section === 'new' && $this->nosummary ) ||
3123            $this->allowBlankSummary
3124        ) {
3125            $out->addHTML( Html::hidden( 'wpIgnoreBlankSummary', true ) );
3126        }
3127
3128        if ( $this->undidRev ) {
3129            $out->addHTML( Html::hidden( 'wpUndidRevision', $this->undidRev ) );
3130        }
3131        if ( $this->undoAfter ) {
3132            $out->addHTML( Html::hidden( 'wpUndoAfter', $this->undoAfter ) );
3133        }
3134
3135        if ( $this->selfRedirect ) {
3136            $out->addHTML( Html::hidden( 'wpIgnoreSelfRedirect', true ) );
3137        }
3138
3139        $autosumm = $this->autoSumm !== '' ? $this->autoSumm : md5( $this->summary );
3140        $out->addHTML( Html::hidden( 'wpAutoSummary', $autosumm ) );
3141
3142        $out->addHTML( Html::hidden( 'oldid', $this->oldid ) );
3143        $out->addHTML( Html::hidden( 'parentRevId', $this->getParentRevId() ) );
3144
3145        $out->addHTML( Html::hidden( 'format', $this->contentFormat ) );
3146        $out->addHTML( Html::hidden( 'model', $this->contentModel ) );
3147
3148        $out->enableOOUI();
3149
3150        if ( $this->section === 'new' ) {
3151            $this->showSummaryInput( true );
3152            $out->addHTML( $this->getSummaryPreview( true ) );
3153        }
3154
3155        $out->addHTML( $this->editFormTextBeforeContent );
3156        if ( $this->isConflict ) {
3157            $currentText = $this->toEditText( $this->getCurrentContent() );
3158
3159            $editConflictHelper = $this->getEditConflictHelper();
3160            $editConflictHelper->setTextboxes( $this->textbox1, $currentText );
3161            $editConflictHelper->setContentModel( $this->contentModel );
3162            $editConflictHelper->setContentFormat( $this->contentFormat );
3163            $out->addHTML( $editConflictHelper->getEditFormHtmlBeforeContent() );
3164
3165            $this->textbox2 = $this->textbox1;
3166            $this->textbox1 = $currentText;
3167        }
3168
3169        if ( !$this->mTitle->isUserConfigPage() ) {
3170            $out->addHTML( self::getEditToolbar() );
3171        }
3172
3173        if ( $this->blankArticle ) {
3174            $out->addHTML( Html::hidden( 'wpIgnoreBlankArticle', true ) );
3175        }
3176
3177        if ( $this->isConflict ) {
3178            // In an edit conflict bypass the overridable content form method
3179            // and fallback to the raw wpTextbox1 since editconflicts can't be
3180            // resolved between page source edits and custom ui edits using the
3181            // custom edit ui.
3182            $conflictTextBoxAttribs = [];
3183            if ( $this->wasDeletedSinceLastEdit() ) {
3184                $conflictTextBoxAttribs['style'] = 'display:none;';
3185            } elseif ( $this->isOldRev ) {
3186                $conflictTextBoxAttribs['class'] = 'mw-textarea-oldrev';
3187            }
3188
3189            // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable
3190            // $editConflictHelper is declard, when isConflict is true
3191            $out->addHTML( $editConflictHelper->getEditConflictMainTextBox( $conflictTextBoxAttribs ) );
3192            // @phan-suppress-next-next-line PhanPossiblyUndeclaredVariable
3193            // $editConflictHelper is declard, when isConflict is true
3194            $out->addHTML( $editConflictHelper->getEditFormHtmlAfterContent() );
3195        } else {
3196            $this->showContentForm();
3197        }
3198
3199        $out->addHTML( $this->editFormTextAfterContent );
3200
3201        $this->showStandardInputs();
3202
3203        $this->showFormAfterText();
3204
3205        $this->showTosSummary();
3206
3207        $this->showEditTools();
3208
3209        $out->addHTML( $this->editFormTextAfterTools . "\n" );
3210
3211        $out->addHTML( $this->makeTemplatesOnThisPageList( $this->getTemplates() ) );
3212
3213        $out->addHTML( Html::rawElement( 'div', [ 'class' => 'hiddencats' ],
3214            Linker::formatHiddenCategories( $this->page->getHiddenCategories() ) ) );
3215
3216        $out->addHTML( Html::rawElement( 'div', [ 'class' => 'limitreport' ],
3217            self::getPreviewLimitReport( $this->mParserOutput ) ) );
3218
3219        $out->addModules( 'mediawiki.action.edit.collapsibleFooter' );
3220
3221        if ( $this->isConflict ) {
3222            try {
3223                $this->showConflict();
3224            } catch ( MWContentSerializationException $ex ) {
3225                // this can't really happen, but be nice if it does.
3226                $msg = $this->context->msg(
3227                    'content-failed-to-parse',
3228                    $this->contentModel,
3229                    $this->contentFormat,
3230                    $ex->getMessage()
3231                );
3232                $out->wrapWikiTextAsInterface( 'error', $msg->plain() );
3233            }
3234        }
3235
3236        // Set a hidden field so JS knows what edit form mode we are in
3237        if ( $this->isConflict ) {
3238            $mode = 'conflict';
3239        } elseif ( $this->preview ) {
3240            $mode = 'preview';
3241        } elseif ( $this->diff ) {
3242            $mode = 'diff';
3243        } else {
3244            $mode = 'text';
3245        }
3246        $out->addHTML( Html::hidden( 'mode', $mode, [ 'id' => 'mw-edit-mode' ] ) );
3247
3248        // Marker for detecting truncated form data.  This must be the last
3249        // parameter sent in order to be of use, so do not move me.
3250        $out->addHTML( Html::hidden( 'wpUltimateParam', true ) );
3251        $out->addHTML( $this->editFormTextBottom . "\n</form>\n" );
3252
3253        if ( !$this->userOptionsLookup->getOption( $user, 'previewontop' ) ) {
3254            $this->displayPreviewArea( $previewOutput, false );
3255        }
3256    }
3257
3258    /**
3259     * Wrapper around TemplatesOnThisPageFormatter to make
3260     * a "templates on this page" list.
3261     *
3262     * @param PageIdentity[] $templates
3263     * @return string HTML
3264     */
3265    public function makeTemplatesOnThisPageList( array $templates ) {
3266        $templateListFormatter = new TemplatesOnThisPageFormatter(
3267            $this->context,
3268            $this->linkRenderer,
3269            $this->linkBatchFactory,
3270            $this->restrictionStore
3271        );
3272
3273        // preview if preview, else section if section, else false
3274        $type = false;
3275        if ( $this->preview ) {
3276            $type = 'preview';
3277        } elseif ( $this->section !== '' ) {
3278            $type = 'section';
3279        }
3280
3281        return Html::rawElement( 'div', [ 'class' => 'templatesUsed' ],
3282            $templateListFormatter->format( $templates, $type )
3283        );
3284    }
3285
3286    /**
3287     * Extract the section title from current section text, if any.
3288     *
3289     * @param string $text
3290     * @return string|false
3291     */
3292    private static function extractSectionTitle( $text ) {
3293        if ( preg_match( "/^(=+)(.+)\\1\\s*(\n|$)/i", $text, $matches ) ) {
3294            return MediaWikiServices::getInstance()->getParser()
3295                ->stripSectionName( trim( $matches[2] ) );
3296        } else {
3297            return false;
3298        }
3299    }
3300
3301    private function showHeader(): void {
3302        $out = $this->context->getOutput();
3303        $user = $this->context->getUser();
3304        if ( $this->isConflict ) {
3305            $this->addExplainConflictHeader();
3306            $this->editRevId = $this->page->getLatest();
3307        } else {
3308            if ( $this->section !== '' && $this->section !== 'new' && $this->summary === '' &&
3309                !$this->preview && !$this->diff
3310            ) {
3311                $sectionTitle = self::extractSectionTitle( $this->textbox1 ); // FIXME: use Content object
3312                if ( $sectionTitle !== false ) {
3313                    $this->summary = "/* $sectionTitle */ ";
3314                }
3315            }
3316
3317            $buttonLabel = $this->context->msg( $this->getSubmitButtonLabel() )->text();
3318
3319            if ( $this->missingComment ) {
3320                $out->wrapWikiMsg( "<div id='mw-missingcommenttext'>\n$1\n</div>", 'missingcommenttext' );
3321            }
3322
3323            if ( $this->missingSummary && $this->section !== 'new' ) {
3324                $out->wrapWikiMsg(
3325                    "<div id='mw-missingsummary'>\n$1\n</div>",
3326                    [ 'missingsummary', $buttonLabel ]
3327                );
3328            }
3329
3330            if ( $this->missingSummary && $this->section === 'new' ) {
3331                $out->wrapWikiMsg(
3332                    "<div id='mw-missingcommentheader'>\n$1\n</div>",
3333                    [ 'missingcommentheader', $buttonLabel ]
3334                );
3335            }
3336
3337            if ( $this->blankArticle ) {
3338                $out->wrapWikiMsg(
3339                    "<div id='mw-blankarticle'>\n$1\n</div>",
3340                    [ 'blankarticle', $buttonLabel ]
3341                );
3342            }
3343
3344            if ( $this->selfRedirect ) {
3345                $out->wrapWikiMsg(
3346                    "<div id='mw-selfredirect'>\n$1\n</div>",
3347                    [ 'selfredirect', $buttonLabel ]
3348                );
3349            }
3350
3351            if ( $this->hookError !== '' ) {
3352                $out->addWikiTextAsInterface( $this->hookError );
3353            }
3354
3355            if ( $this->section != 'new' ) {
3356                $revRecord = $this->mArticle->fetchRevisionRecord();
3357                if ( $revRecord && $revRecord instanceof RevisionStoreRecord ) {
3358                    // Let sysop know that this will make private content public if saved
3359
3360                    if ( !$revRecord->userCan( RevisionRecord::DELETED_TEXT, $user ) ) {
3361                        $out->addHTML(
3362                            Html::warningBox(
3363                                $out->msg( 'rev-deleted-text-permission', $this->mTitle->getPrefixedDBkey() )->parse(),
3364                                'plainlinks'
3365                            )
3366                        );
3367                    } elseif ( $revRecord->isDeleted( RevisionRecord::DELETED_TEXT ) ) {
3368                        $out->addHTML(
3369                            Html::warningBox(
3370                                // title used in wikilinks, should not contain whitespaces
3371                                $out->msg( 'rev-deleted-text-view', $this->mTitle->getPrefixedDBkey() )->parse(),
3372                                'plainlinks'
3373                            )
3374                        );
3375                    }
3376
3377                    if ( !$revRecord->isCurrent() ) {
3378                        $this->mArticle->setOldSubtitle( $revRecord->getId() );
3379                        $this->isOldRev = true;
3380                    }
3381                } elseif ( $this->mTitle->exists() ) {
3382                    // Something went wrong
3383
3384                    $out->addHTML(
3385                        Html::errorBox(
3386                            $out->msg( 'missing-revision', $this->oldid )->parse()
3387                        )
3388                    );
3389                }
3390            }
3391        }
3392
3393        $this->addLongPageWarningHeader();
3394    }
3395
3396    /**
3397     * Helper function for summary input functions, which returns the necessary
3398     * attributes for the input.
3399     *
3400     * @param array $inputAttrs Array of attrs to use on the input
3401     * @return array
3402     */
3403    private function getSummaryInputAttributes( array $inputAttrs ): array {
3404        // HTML maxlength uses "UTF-16 code units", which means that characters outside BMP
3405        // (e.g. emojis) count for two each. This limit is overridden in JS to instead count
3406        // Unicode codepoints.
3407        return $inputAttrs + [
3408            'id' => 'wpSummary',
3409            'name' => 'wpSummary',
3410            'maxlength' => CommentStore::COMMENT_CHARACTER_LIMIT,
3411            'tabindex' => 1,
3412            'size' => 60,
3413            'spellcheck' => 'true',
3414        ];
3415    }
3416
3417    /**
3418     * Builds a standard summary input with a label.
3419     *
3420     * @param string $summary The value of the summary input
3421     * @param string $labelText The html to place inside the label
3422     * @param array $inputAttrs Array of attrs to use on the input
3423     *
3424     * @return OOUI\FieldLayout OOUI FieldLayout with Label and Input
3425     */
3426    private function getSummaryInputWidget( $summary, string $labelText, array $inputAttrs ): FieldLayout {
3427        $inputAttrs = OOUI\Element::configFromHtmlAttributes(
3428            $this->getSummaryInputAttributes( $inputAttrs )
3429        );
3430        $inputAttrs += [
3431            'title' => Linker::titleAttrib( 'summary' ),
3432            'accessKey' => Linker::accesskey( 'summary' ),
3433        ];
3434
3435        // For compatibility with old scripts and extensions, we want the legacy 'id' on the `<input>`
3436        $inputAttrs['inputId'] = $inputAttrs['id'];
3437        $inputAttrs['id'] = 'wpSummaryWidget';
3438
3439        return new OOUI\FieldLayout(
3440            new OOUI\TextInputWidget( [
3441                'value' => $summary,
3442                'infusable' => true,
3443            ] + $inputAttrs ),
3444            [
3445                'label' => new OOUI\HtmlSnippet( $labelText ),
3446                'align' => 'top',
3447                'id' => 'wpSummaryLabel',
3448                'classes' => [ $this->missingSummary ? 'mw-summarymissed' : 'mw-summary' ],
3449            ]
3450        );
3451    }
3452
3453    /**
3454     * @param bool $isSubjectPreview True if this is the section subject/title
3455     *   up top, or false if this is the comment summary
3456     *   down below the textarea
3457     */
3458    private function showSummaryInput( bool $isSubjectPreview ): void {
3459        # Add a class if 'missingsummary' is triggered to allow styling of the summary line
3460        $summaryClass = $this->missingSummary ? 'mw-summarymissed' : 'mw-summary';
3461        if ( $isSubjectPreview ) {
3462            if ( $this->nosummary ) {
3463                return;
3464            }
3465        } elseif ( !$this->mShowSummaryField ) {
3466            return;
3467        }
3468
3469        $labelText = $this->context->msg( $isSubjectPreview ? 'subject' : 'summary' )->parse();
3470        $this->context->getOutput()->addHTML(
3471            $this->getSummaryInputWidget(
3472                $isSubjectPreview ? $this->sectiontitle : $this->summary,
3473                $labelText,
3474                [ 'class' => $summaryClass ]
3475            )
3476        );
3477    }
3478
3479    /**
3480     * @param bool $isSubjectPreview True if this is the section subject/title
3481     *   up top, or false if this is the comment summary
3482     *   down below the textarea
3483     * @return string
3484     */
3485    private function getSummaryPreview( bool $isSubjectPreview ): string {
3486        // avoid spaces in preview, gets always trimmed on save
3487        $summary = trim( $this->summary );
3488        if ( $summary === '' || ( !$this->preview && !$this->diff ) ) {
3489            return "";
3490        }
3491
3492        $commentFormatter = MediaWikiServices::getInstance()->getCommentFormatter();
3493        $summary = $this->context->msg( 'summary-preview' )->parse()
3494            . $commentFormatter->formatBlock( $summary, $this->mTitle, $isSubjectPreview );
3495        return Html::rawElement( 'div', [ 'class' => 'mw-summary-preview' ], $summary );
3496    }
3497
3498    private function showFormBeforeText(): void {
3499        $out = $this->context->getOutput();
3500        $out->addHTML( Html::hidden( 'wpSection', $this->section ) );
3501        $out->addHTML( Html::hidden( 'wpStarttime', $this->starttime ) );
3502        $out->addHTML( Html::hidden( 'wpEdittime', $this->edittime ) );
3503        $out->addHTML( Html::hidden( 'editRevId', $this->editRevId ) );
3504        $out->addHTML( Html::hidden( 'wpScrolltop', $this->scrolltop, [ 'id' => 'wpScrolltop' ] ) );
3505    }
3506
3507    protected function showFormAfterText() {
3508        /**
3509         * To make it harder for someone to slip a user a page
3510         * which submits an edit form to the wiki without their
3511         * knowledge, a random token is associated with the login
3512         * session. If it's not passed back with the submission,
3513         * we won't save the page, or render user JavaScript and
3514         * CSS previews.
3515         *
3516         * For anon editors, who may not have a session, we just
3517         * include the constant suffix to prevent editing from
3518         * broken text-mangling proxies.
3519         */
3520        $this->context->getOutput()->addHTML(
3521            "\n" .
3522            Html::hidden( "wpEditToken", $this->context->getUser()->getEditToken() ) .
3523            "\n"
3524        );
3525    }
3526
3527    /**
3528     * Subpage overridable method for printing the form for page content editing
3529     * By default this simply outputs wpTextbox1
3530     * Subclasses can override this to provide a custom UI for editing;
3531     * be it a form, or simply wpTextbox1 with a modified content that will be
3532     * reverse modified when extracted from the post data.
3533     * Note that this is basically the inverse for importContentFormData
3534     */
3535    protected function showContentForm() {
3536        $this->showTextbox1();
3537    }
3538
3539    private function showTextbox1(): void {
3540        if ( $this->formtype === 'save' && $this->wasDeletedSinceLastEdit() ) {
3541            $attribs = [ 'style' => 'display:none;' ];
3542        } else {
3543            $builder = new TextboxBuilder();
3544            $classes = $builder->getTextboxProtectionCSSClasses( $this->getTitle() );
3545
3546            # Is an old revision being edited?
3547            if ( $this->isOldRev ) {
3548                $classes[] = 'mw-textarea-oldrev';
3549            }
3550
3551            $attribs = [
3552                'aria-label' => $this->context->msg( 'edit-textarea-aria-label' )->text(),
3553                'tabindex' => 1
3554            ];
3555
3556            $attribs = $builder->mergeClassesIntoAttributes( $classes, $attribs );
3557        }
3558
3559        $this->showTextbox(
3560            $this->textbox1,
3561            'wpTextbox1',
3562            $attribs
3563        );
3564    }
3565
3566    protected function showTextbox( $text, $name, $customAttribs = [] ) {
3567        $builder = new TextboxBuilder();
3568        $attribs = $builder->buildTextboxAttribs(
3569            $name,
3570            $customAttribs,
3571            $this->context->getUser(),
3572            $this->mTitle
3573        );
3574
3575        $this->context->getOutput()->addHTML(
3576            Html::textarea( $name, $builder->addNewLineAtEnd( $text ), $attribs )
3577        );
3578    }
3579
3580    private function displayPreviewArea( string $previewOutput, bool $isOnTop ): void {
3581        $attribs = [ 'id' => 'wikiPreview' ];
3582        if ( $isOnTop ) {
3583            $attribs['class'] = 'ontop';
3584        }
3585        if ( $this->formtype !== 'preview' ) {
3586            $attribs['style'] = 'display: none;';
3587        }
3588
3589        $out = $this->context->getOutput();
3590        $out->addHTML( Html::openElement( 'div', $attribs ) );
3591
3592        if ( $this->formtype === 'preview' ) {
3593            $this->showPreview( $previewOutput );
3594        }
3595
3596        $out->addHTML( '</div>' );
3597
3598        if ( $this->formtype === 'diff' ) {
3599            try {
3600                $this->showDiff();
3601            } catch ( MWContentSerializationException $ex ) {
3602                $msg = $this->context->msg(
3603                    'content-failed-to-parse',
3604                    $this->contentModel,
3605                    $this->contentFormat,
3606                    $ex->getMessage()
3607                );
3608                $out->wrapWikiTextAsInterface( 'error', $msg->plain() );
3609            }
3610        }
3611    }
3612
3613    /**
3614     * Append preview output to OutputPage.
3615     * Includes category rendering if this is a category page.
3616     *
3617     * @param string $text The HTML to be output for the preview.
3618     */
3619    private function showPreview( string $text ): void {
3620        if ( $this->mArticle instanceof CategoryPage ) {
3621            $this->mArticle->openShowCategory();
3622        }
3623        # This hook seems slightly odd here, but makes things more
3624        # consistent for extensions.
3625        $out = $this->context->getOutput();
3626        $this->getHookRunner()->onOutputPageBeforeHTML( $out, $text );
3627        $out->addHTML( $text );
3628        if ( $this->mArticle instanceof CategoryPage ) {
3629            $this->mArticle->closeShowCategory();
3630        }
3631    }
3632
3633    /**
3634     * Get a diff between the current contents of the edit box and the
3635     * version of the page we're editing from.
3636     *
3637     * If this is a section edit, we'll replace the section as for final
3638     * save and then make a comparison.
3639     */
3640    public function showDiff() {
3641        $oldtitlemsg = 'currentrev';
3642        # if message does not exist, show diff against the preloaded default
3643        if ( $this->mTitle->getNamespace() === NS_MEDIAWIKI && !$this->mTitle->exists() ) {
3644            $oldtext = $this->mTitle->getDefaultMessageText();
3645            if ( $oldtext !== false ) {
3646                $oldtitlemsg = 'defaultmessagetext';
3647                $oldContent = $this->toEditContent( $oldtext );
3648            } else {
3649                $oldContent = null;
3650            }
3651        } else {
3652            $oldContent = $this->getCurrentContent();
3653        }
3654
3655        $textboxContent = $this->toEditContent( $this->textbox1 );
3656        if ( $this->editRevId !== null ) {
3657            $newContent = $this->page->replaceSectionAtRev(
3658                $this->section, $textboxContent, $this->sectiontitle, $this->editRevId
3659            );
3660        } else {
3661            $newContent = $this->page->replaceSectionContent(
3662                $this->section, $textboxContent, $this->sectiontitle, $this->edittime
3663            );
3664        }
3665
3666        if ( $newContent ) {
3667            $this->getHookRunner()->onEditPageGetDiffContent( $this, $newContent );
3668
3669            $user = $this->getUserForPreview();
3670            $parserOptions = ParserOptions::newFromUserAndLang( $user,
3671                MediaWikiServices::getInstance()->getContentLanguage() );
3672            $services = MediaWikiServices::getInstance();
3673            $contentTransformer = $services->getContentTransformer();
3674            $newContent = $contentTransformer->preSaveTransform( $newContent, $this->mTitle, $user, $parserOptions );
3675        }
3676
3677        if ( ( $oldContent && !$oldContent->isEmpty() ) || ( $newContent && !$newContent->isEmpty() ) ) {
3678            $oldtitle = $this->context->msg( $oldtitlemsg )->parse();
3679            $newtitle = $this->context->msg( 'yourtext' )->parse();
3680
3681            if ( !$oldContent ) {
3682                $oldContent = $newContent->getContentHandler()->makeEmptyContent();
3683            }
3684
3685            if ( !$newContent ) {
3686                $newContent = $oldContent->getContentHandler()->makeEmptyContent();
3687            }
3688
3689            $de = $oldContent->getContentHandler()->createDifferenceEngine( $this->context );
3690            $de->setContent( $oldContent, $newContent );
3691
3692            $difftext = $de->getDiff( $oldtitle, $newtitle );
3693            $de->showDiffStyle();
3694        } else {
3695            $difftext = '';
3696        }
3697
3698        $this->context->getOutput()->addHTML( Html::rawElement( 'div', [ 'id' => 'wikiDiff' ], $difftext ) );
3699    }
3700
3701    /**
3702     * Give a chance for site and per-namespace customizations of
3703     * terms of service summary link that might exist separately
3704     * from the copyright notice.
3705     *
3706     * This will display between the save button and the edit tools,
3707     * so should remain short!
3708     */
3709    private function showTosSummary(): void {
3710        $msgKey = 'editpage-tos-summary';
3711        $this->getHookRunner()->onEditPageTosSummary( $this->mTitle, $msgKey );
3712        $msg = $this->context->msg( $msgKey );
3713        if ( !$msg->isDisabled() ) {
3714            $this->context->getOutput()->addHTML( Html::rawElement(
3715                'div',
3716                [ 'class' => 'mw-tos-summary' ],
3717                $msg->parseAsBlock()
3718            ) );
3719        }
3720    }
3721
3722    /**
3723     * Inserts optional text shown below edit and upload forms. Can be used to offer special
3724     * characters not present on most keyboards for copying/pasting.
3725     */
3726    private function showEditTools(): void {
3727        $this->context->getOutput()->addHTML( Html::rawElement(
3728            'div',
3729            [ 'class' => 'mw-editTools' ],
3730            $this->context->msg( 'edittools' )->inContentLanguage()->parse()
3731        ) );
3732    }
3733
3734    /**
3735     * Get the copyright warning.
3736     *
3737     * @param PageReference $page
3738     * @param string $format Output format, valid values are any function of a Message object
3739     *   (e.g. 'parse', 'plain')
3740     * @param MessageLocalizer $localizer
3741     * @return string
3742     */
3743    public static function getCopyrightWarning( PageReference $page, string $format, MessageLocalizer $localizer ) {
3744        $services = MediaWikiServices::getInstance();
3745        $rightsText = $services->getMainConfig()->get( MainConfigNames::RightsText );
3746        if ( $rightsText ) {
3747            $copywarnMsg = [ 'copyrightwarning',
3748                '[[' . $localizer->msg( 'copyrightpage' )->inContentLanguage()->text() . ']]',
3749                $rightsText ];
3750        } else {
3751            $copywarnMsg = [ 'copyrightwarning2',
3752                '[[' . $localizer->msg( 'copyrightpage' )->inContentLanguage()->text() . ']]' ];
3753        }
3754        // Allow for site and per-namespace customization of contribution/copyright notice.
3755        $title = Title::newFromPageReference( $page );
3756        ( new HookRunner( $services->getHookContainer() ) )->onEditPageCopyrightWarning( $title, $copywarnMsg );
3757        if ( !$copywarnMsg ) {
3758            return '';
3759        }
3760
3761        $msg = $localizer->msg( ...$copywarnMsg )->page( $page );
3762        return Html::rawElement( 'div', [ 'id' => 'editpage-copywarn' ], $msg->$format() );
3763    }
3764
3765    /**
3766     * Get the Limit report for page previews
3767     *
3768     * @since 1.22
3769     * @param ParserOutput|null $output ParserOutput object from the parse
3770     * @return string HTML
3771     */
3772    public static function getPreviewLimitReport( ?ParserOutput $output = null ) {
3773        if ( !$output || !$output->getLimitReportData() ) {
3774            return '';
3775        }
3776
3777        $limitReport = Html::rawElement( 'div', [ 'class' => 'mw-limitReportExplanation' ],
3778            wfMessage( 'limitreport-title' )->parseAsBlock()
3779        );
3780
3781        // Show/hide animation doesn't work correctly on a table, so wrap it in a div.
3782        $limitReport .= Html::openElement( 'div', [ 'class' => 'preview-limit-report-wrapper' ] );
3783
3784        $limitReport .= Html::openElement( 'table', [
3785            'class' => 'preview-limit-report wikitable'
3786        ] ) .
3787            Html::openElement( 'tbody' );
3788
3789        $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
3790        foreach ( $output->getLimitReportData() as $key => $value ) {
3791            if ( $hookRunner->onParserLimitReportFormat( $key, $value, $limitReport, true, true ) ) {
3792                $keyMsg = wfMessage( $key );
3793                $valueMsg = wfMessage( [ "$key-value-html", "$key-value" ] );
3794                if ( !$valueMsg->exists() ) {
3795                    // This is formatted raw, not as localized number.
3796                    // If you want the parameter formatted as a number,
3797                    // define the `$key-value` message.
3798                    $valueMsg = ( new RawMessage( '$1' ) )->params( $value );
3799                } else {
3800                    // If you define the `$key-value` or `$key-value-html`
3801                    // message then the argument *must* be numeric.
3802                    $valueMsg = $valueMsg->numParams( $value );
3803                }
3804                if ( !$keyMsg->isDisabled() && !$valueMsg->isDisabled() ) {
3805                    $limitReport .= Html::openElement( 'tr' ) .
3806                        Html::rawElement( 'th', [], $keyMsg->parse() ) .
3807                        Html::rawElement( 'td', [], $valueMsg->parse() ) .
3808                        Html::closeElement( 'tr' );
3809                }
3810            }
3811        }
3812
3813        $limitReport .= Html::closeElement( 'tbody' ) .
3814            Html::closeElement( 'table' ) .
3815            Html::closeElement( 'div' );
3816
3817        return $limitReport;
3818    }
3819
3820    protected function showStandardInputs( &$tabindex = 2 ) {
3821        $out = $this->context->getOutput();
3822        $out->addHTML( "<div class='editOptions'>\n" );
3823
3824        if ( $this->section !== 'new' ) {
3825            $this->showSummaryInput( false );
3826            $out->addHTML( $this->getSummaryPreview( false ) );
3827        }
3828
3829        // When previewing, override the selected dropdown option to select whatever was posted
3830        // (if it's a valid option) rather than the current value for watchlistExpiry.
3831        // See also above in $this->importFormData().
3832        $expiryFromRequest = null;
3833        if ( $this->preview || $this->diff || $this->isConflict ) {
3834            $expiryFromRequest = $this->getContext()->getRequest()->getText( 'wpWatchlistExpiry' );
3835        }
3836
3837        $checkboxes = $this->getCheckboxesWidget(
3838            $tabindex,
3839            [ 'minor' => $this->minoredit, 'watch' => $this->watchthis, 'wpWatchlistExpiry' => $expiryFromRequest ]
3840        );
3841        $checkboxesHTML = new OOUI\HorizontalLayout( [ 'items' => array_values( $checkboxes ) ] );
3842
3843        $out->addHTML( "<div class='editCheckboxes'>" . $checkboxesHTML . "</div>\n" );
3844
3845        // Show copyright warning.
3846        $out->addHTML( self::getCopyrightWarning( $this->mTitle, 'parse', $this->context ) );
3847        $out->addHTML( $this->editFormTextAfterWarn );
3848
3849        $out->addHTML( "<div class='editButtons'>\n" );
3850        $out->addHTML( implode( "\n", $this->getEditButtons( $tabindex ) ) . "\n" );
3851
3852        $cancel = $this->getCancelLink( $tabindex++ );
3853
3854        $edithelp = $this->getHelpLink() .
3855            $this->context->msg( 'word-separator' )->escaped() .
3856            $this->context->msg( 'newwindow' )->parse();
3857
3858        $out->addHTML( "    <span class='cancelLink'>{$cancel}</span>\n" );
3859        $out->addHTML( "    <span class='editHelp'>{$edithelp}</span>\n" );
3860        $out->addHTML( "</div><!-- editButtons -->\n" );
3861
3862        $this->getHookRunner()->onEditPage__showStandardInputs_options( $this, $out, $tabindex );
3863
3864        $out->addHTML( "</div><!-- editOptions -->\n" );
3865    }
3866
3867    /**
3868     * Show an edit conflict. textbox1 is already shown in showEditForm().
3869     * If you want to use another entry point to this function, be careful.
3870     */
3871    private function showConflict(): void {
3872        $out = $this->context->getOutput();
3873        if ( $this->getHookRunner()->onEditPageBeforeConflictDiff( $this, $out ) ) {
3874            $this->incrementConflictStats();
3875
3876            $this->getEditConflictHelper()->showEditFormTextAfterFooters();
3877        }
3878    }
3879
3880    private function incrementConflictStats(): void {
3881        $this->getEditConflictHelper()->incrementConflictStats( $this->context->getUser() );
3882    }
3883
3884    /**
3885     * @return string
3886     */
3887    private function getHelpLink(): string {
3888        $message = $this->context->msg( 'edithelppage' )->inContentLanguage()->text();
3889        $editHelpUrl = Skin::makeInternalOrExternalUrl( $message );
3890        return Html::element( 'a', [
3891            'href' => $editHelpUrl,
3892            'target' => 'helpwindow'
3893        ], $this->context->msg( 'edithelp' )->text() );
3894    }
3895
3896    /**
3897     * @param int $tabindex Current tabindex
3898     * @return ButtonWidget
3899     */
3900    private function getCancelLink( int $tabindex ): ButtonWidget {
3901        $cancelParams = [];
3902        if ( !$this->isConflict && $this->oldid > 0 ) {
3903            $cancelParams['oldid'] = $this->oldid;
3904        } elseif ( $this->getContextTitle()->isRedirect() ) {
3905            $cancelParams['redirect'] = 'no';
3906        }
3907
3908        return new OOUI\ButtonWidget( [
3909            'id' => 'mw-editform-cancel',
3910            'tabIndex' => $tabindex,
3911            'href' => $this->getContextTitle()->getLinkURL( $cancelParams ),
3912            'label' => new OOUI\HtmlSnippet( $this->context->msg( 'cancel' )->parse() ),
3913            'framed' => false,
3914            'infusable' => true,
3915            'flags' => 'destructive',
3916        ] );
3917    }
3918
3919    /**
3920     * Returns the URL to use in the form's action attribute.
3921     * This is used by EditPage subclasses when simply customizing the action
3922     * variable in the constructor is not enough. This can be used when the
3923     * EditPage lives inside of a Special page rather than a custom page action.
3924     *
3925     * @param Title $title Title object for which is being edited (where we go to for &action= links)
3926     * @return string
3927     */
3928    protected function getActionURL( Title $title ) {
3929        return $title->getLocalURL( [ 'action' => $this->action ] );
3930    }
3931
3932    /**
3933     * Check if a page was deleted while the user was editing it, before submit.
3934     * Note that we rely on the logging table, which hasn't been always there,
3935     * but that doesn't matter, because this only applies to brand new
3936     * deletes.
3937     * @return bool
3938     */
3939    private function wasDeletedSinceLastEdit(): bool {
3940        if ( $this->deletedSinceEdit !== null ) {
3941            return $this->deletedSinceEdit;
3942        }
3943
3944        $this->deletedSinceEdit = false;
3945
3946        if ( !$this->mTitle->exists() && $this->mTitle->hasDeletedEdits() ) {
3947            $this->lastDelete = $this->getLastDelete();
3948            if ( $this->lastDelete ) {
3949                $deleteTime = wfTimestamp( TS_MW, $this->lastDelete->log_timestamp );
3950                if ( $deleteTime > $this->starttime ) {
3951                    $this->deletedSinceEdit = true;
3952                }
3953            }
3954        }
3955
3956        return $this->deletedSinceEdit;
3957    }
3958
3959    /**
3960     * Get the last log record of this page being deleted, if ever.  This is
3961     * used to detect whether a delete occurred during editing.
3962     * @return stdClass|null
3963     */
3964    private function getLastDelete(): ?stdClass {
3965        $dbr = $this->dbProvider->getReplicaDatabase();
3966        $commentQuery = $this->commentStore->getJoin( 'log_comment' );
3967        $data = $dbr->newSelectQueryBuilder()
3968            ->select( [
3969                'log_type',
3970                'log_action',
3971                'log_timestamp',
3972                'log_namespace',
3973                'log_title',
3974                'log_params',
3975                'log_deleted',
3976                'actor_name'
3977            ] )
3978            ->from( 'logging' )
3979            ->join( 'actor', null, 'actor_id=log_actor' )
3980            ->where( [
3981                'log_namespace' => $this->mTitle->getNamespace(),
3982                'log_title' => $this->mTitle->getDBkey(),
3983                'log_type' => 'delete',
3984                'log_action' => 'delete',
3985            ] )
3986            ->orderBy( [ 'log_timestamp', 'log_id' ], SelectQueryBuilder::SORT_DESC )
3987            ->queryInfo( $commentQuery )
3988            ->caller( __METHOD__ )
3989            ->fetchRow();
3990        // Quick paranoid permission checks...
3991        if ( $data !== false ) {
3992            if ( $data->log_deleted & LogPage::DELETED_USER ) {
3993                $data->actor_name = $this->context->msg( 'rev-deleted-user' )->escaped();
3994            }
3995
3996            if ( $data->log_deleted & LogPage::DELETED_COMMENT ) {
3997                $data->log_comment_text = $this->context->msg( 'rev-deleted-comment' )->escaped();
3998                $data->log_comment_data = null;
3999            }
4000        }
4001
4002        return $data ?: null;
4003    }
4004
4005    /**
4006     * Get the rendered text for previewing.
4007     * @throws MWException
4008     * @return string
4009     */
4010    public function getPreviewText() {
4011        $out = $this->context->getOutput();
4012        $config = $this->context->getConfig();
4013
4014        if ( $config->get( MainConfigNames::RawHtml ) && !$this->mTokenOk ) {
4015            // Could be an offsite preview attempt. This is very unsafe if
4016            // HTML is enabled, as it could be an attack.
4017            $parsedNote = '';
4018            if ( $this->textbox1 !== '' ) {
4019                // Do not put big scary notice, if previewing the empty
4020                // string, which happens when you initially edit
4021                // a category page, due to automatic preview-on-open.
4022                $parsedNote = Html::rawElement( 'div', [ 'class' => 'previewnote' ],
4023                    $out->parseAsInterface(
4024                        $this->context->msg( 'session_fail_preview_html' )->plain()
4025                    ) );
4026            }
4027            $this->incrementEditFailureStats( 'session_loss' );
4028            return $parsedNote;
4029        }
4030
4031        $note = '';
4032
4033        try {
4034            $content = $this->toEditContent( $this->textbox1 );
4035
4036            $previewHTML = '';
4037            if ( !$this->getHookRunner()->onAlternateEditPreview(
4038                $this, $content, $previewHTML, $this->mParserOutput )
4039            ) {
4040                return $previewHTML;
4041            }
4042
4043            # provide a anchor link to the editform
4044            $continueEditing = '<span class="mw-continue-editing">' .
4045                '[[#' . self::EDITFORM_ID . '|' .
4046                $this->context->getLanguage()->getArrow() . ' ' .
4047                $this->context->msg( 'continue-editing' )->text() . ']]</span>';
4048            if ( $this->mTriedSave && !$this->mTokenOk ) {
4049                $note = $this->context->msg( 'session_fail_preview' )->plain();
4050                $this->incrementEditFailureStats( 'session_loss' );
4051            } elseif ( $this->incompleteForm ) {
4052                $note = $this->context->msg( 'edit_form_incomplete' )->plain();
4053                if ( $this->mTriedSave ) {
4054                    $this->incrementEditFailureStats( 'incomplete_form' );
4055                }
4056            } else {
4057                $note = $this->context->msg( 'previewnote' )->plain() . ' ' . $continueEditing;
4058            }
4059
4060            # don't parse non-wikitext pages, show message about preview
4061            if ( $this->mTitle->isUserConfigPage() || $this->mTitle->isSiteConfigPage() ) {
4062                if ( $this->mTitle->isUserConfigPage() ) {
4063                    $level = 'user';
4064                } elseif ( $this->mTitle->isSiteConfigPage() ) {
4065                    $level = 'site';
4066                } else {
4067                    $level = false;
4068                }
4069
4070                if ( $content->getModel() === CONTENT_MODEL_CSS ) {
4071                    $format = 'css';
4072                    if ( $level === 'user' && !$config->get( MainConfigNames::AllowUserCss ) ) {
4073                        $format = false;
4074                    }
4075                } elseif ( $content->getModel() === CONTENT_MODEL_JSON ) {
4076                    $format = 'json';
4077                    if ( $level === 'user' /* No comparable 'AllowUserJson' */ ) {
4078                        $format = false;
4079                    }
4080                } elseif ( $content->getModel() === CONTENT_MODEL_JAVASCRIPT ) {
4081                    $format = 'js';
4082                    if ( $level === 'user' && !$config->get( MainConfigNames::AllowUserJs ) ) {
4083                        $format = false;
4084                    }
4085                } else {
4086                    $format = false;
4087                }
4088
4089                # Used messages to make sure grep find them:
4090                # Messages: usercsspreview, userjsonpreview, userjspreview,
4091                #   sitecsspreview, sitejsonpreview, sitejspreview
4092                if ( $level && $format ) {
4093                    $note = "<div id='mw-{$level}{$format}preview'>" .
4094                        $this->context->msg( "{$level}{$format}preview" )->plain() .
4095                        ' ' . $continueEditing . "</div>";
4096                }
4097            }
4098
4099            if ( $this->section === "new" ) {
4100                $content = $content->addSectionHeader( $this->sectiontitle );
4101            }
4102
4103            // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
4104            $this->getHookRunner()->onEditPageGetPreviewContent( $this, $content );
4105
4106            $parserResult = $this->doPreviewParse( $content );
4107            $parserOutput = $parserResult['parserOutput'];
4108            $previewHTML = $parserResult['html'];
4109            $this->mParserOutput = $parserOutput;
4110            $out->addParserOutputMetadata( $parserOutput );
4111            if ( $out->userCanPreview() ) {
4112                $out->addContentOverride( $this->getTitle(), $content );
4113            }
4114
4115            if ( count( $parserOutput->getWarnings() ) ) {
4116                $note .= "\n\n" . implode( "\n\n", $parserOutput->getWarnings() );
4117            }
4118
4119        } catch ( MWContentSerializationException $ex ) {
4120            $m = $this->context->msg(
4121                'content-failed-to-parse',
4122                $this->contentModel,
4123                $this->contentFormat,
4124                $ex->getMessage()
4125            );
4126            $note .= "\n\n" . $m->plain(); # gets parsed down below
4127            $previewHTML = '';
4128        }
4129
4130        if ( $this->isConflict ) {
4131            $conflict = Html::warningBox(
4132                $this->context->msg( 'previewconflict' )->escaped(),
4133                'mw-previewconflict'
4134            );
4135        } else {
4136            $conflict = '';
4137        }
4138
4139        $previewhead = Html::rawElement(
4140            'div', [ 'class' => 'previewnote' ],
4141            Html::rawElement(
4142                'h2', [ 'id' => 'mw-previewheader' ],
4143                $this->context->msg( 'preview' )->escaped()
4144            ) .
4145            Html::warningBox(
4146                $out->parseAsInterface( $note )
4147            ) . $conflict
4148        );
4149
4150        return $previewhead . $previewHTML . $this->previewTextAfterContent;
4151    }
4152
4153    private function incrementEditFailureStats( string $failureType ): void {
4154        MediaWikiServices::getInstance()->getStatsFactory()
4155            ->getCounter( 'edit_failure_total' )
4156            ->setLabel( 'cause', $failureType )
4157            ->setLabel( 'namespace', 'n/a' )
4158            ->setLabel( 'user_bucket', 'n/a' )
4159            ->copyToStatsdAt( 'edit.failures.' . $failureType )
4160            ->increment();
4161    }
4162
4163    /**
4164     * Get parser options for a preview
4165     * @return ParserOptions
4166     */
4167    protected function getPreviewParserOptions() {
4168        $parserOptions = $this->page->makeParserOptions( $this->context );
4169        $parserOptions->setRenderReason( 'page-preview' );
4170        $parserOptions->setIsPreview( true );
4171        $parserOptions->setIsSectionPreview( $this->section !== null && $this->section !== '' );
4172
4173        // XXX: we could call $parserOptions->setCurrentRevisionRecordCallback here to force the
4174        // current revision to be null during PST, until setupFakeRevision is called on
4175        // the ParserOptions. Currently, we rely on Parser::getRevisionRecordObject() to ignore
4176        // existing revisions in preview mode.
4177
4178        return $parserOptions;
4179    }
4180
4181    /**
4182     * Parse the page for a preview. Subclasses may override this class, in order
4183     * to parse with different options, or to otherwise modify the preview HTML.
4184     *
4185     * @param Content $content The page content
4186     * @return array with keys:
4187     *   - parserOutput: The ParserOutput object
4188     *   - html: The HTML to be displayed
4189     */
4190    protected function doPreviewParse( Content $content ) {
4191        $user = $this->getUserForPreview();
4192        $parserOptions = $this->getPreviewParserOptions();
4193
4194        // NOTE: preSaveTransform doesn't have a fake revision to operate on.
4195        // Parser::getRevisionRecordObject() will return null in preview mode,
4196        // causing the context user to be used for {{subst:REVISIONUSER}}.
4197        // XXX: Alternatively, we could also call setupFakeRevision()
4198        // before PST with $content.
4199        $services = MediaWikiServices::getInstance();
4200        $contentTransformer = $services->getContentTransformer();
4201        $contentRenderer = $services->getContentRenderer();
4202        $pstContent = $contentTransformer->preSaveTransform( $content, $this->mTitle, $user, $parserOptions );
4203        $parserOutput = $contentRenderer->getParserOutput( $pstContent, $this->mTitle, null, $parserOptions );
4204        $out = $this->context->getOutput();
4205        $skin = $out->getSkin();
4206        $skinOptions = $skin->getOptions();
4207        // TODO T371004 move runOutputPipeline out of $parserOutput
4208        // TODO T371022 ideally we clone here, but for now let's reproduce getText behaviour
4209        $oldHtml = $parserOutput->getRawText();
4210        $html = $parserOutput->runOutputPipeline( $parserOptions, [
4211            'allowClone' => 'false',
4212            'userLang' => $skin->getLanguage(),
4213            'injectTOC' => $skinOptions['toc'],
4214            'enableSectionEditLinks' => false,
4215            'includeDebugInfo' => true,
4216        ] )->getContentHolderText();
4217        $parserOutput->setRawText( $oldHtml );
4218        return [
4219            'parserOutput' => $parserOutput,
4220            'html' => $html
4221        ];
4222    }
4223
4224    /**
4225     * @return Title[]
4226     */
4227    public function getTemplates() {
4228        if ( $this->preview || $this->section !== '' ) {
4229            $templates = [];
4230            if ( !isset( $this->mParserOutput ) ) {
4231                return $templates;
4232            }
4233            foreach (
4234                $this->mParserOutput->getLinkList( ParserOutputLinkTypes::TEMPLATE )
4235                as [ 'link' => $link ]
4236            ) {
4237                $templates[] = Title::newFromLinkTarget( $link );
4238            }
4239            return $templates;
4240        } else {
4241            return $this->mTitle->getTemplateLinksFrom();
4242        }
4243    }
4244
4245    /**
4246     * Allow extensions to provide a toolbar.
4247     *
4248     * @return string|null
4249     */
4250    public static function getEditToolbar() {
4251        $startingToolbar = '<div id="toolbar"></div>';
4252        $toolbar = $startingToolbar;
4253
4254        $hookRunner = new HookRunner( MediaWikiServices::getInstance()->getHookContainer() );
4255        if ( !$hookRunner->onEditPageBeforeEditToolbar( $toolbar ) ) {
4256            return null;
4257        }
4258        // Don't add a pointless `<div>` to the page unless a hook caller populated it
4259        return ( $toolbar === $startingToolbar ) ? null : $toolbar;
4260    }
4261
4262    /**
4263     * Return an array of field definitions. Despite the name, not only checkboxes are supported.
4264     *
4265     * Array keys correspond to the `<input>` 'name' attribute to use for each field.
4266     *
4267     * Array values are associative arrays with the following keys:
4268     *  - 'label-message' (required): message for label text
4269     *  - 'id' (required): 'id' attribute for the `<input>`
4270     *  - 'default' (required): default checkedness (true or false)
4271     *  - 'title-message' (optional): used to generate 'title' attribute for the `<label>`
4272     *  - 'tooltip' (optional): used to generate 'title' and 'accesskey' attributes
4273     *    from messages like 'tooltip-foo', 'accesskey-foo'
4274     *  - 'label-id' (optional): 'id' attribute for the `<label>`
4275     *  - 'legacy-name' (optional): short name for backwards-compatibility
4276     *  - 'class' (optional): PHP class name of the OOUI widget to use. Defaults to
4277     *    CheckboxInputWidget.
4278     *  - 'options' (optional): options to use for DropdownInputWidget,
4279     *    ComboBoxInputWidget, etc. following the structure as given in the documentation for those
4280     *    classes.
4281     *  - 'value-attr' (optional): name of the widget config option for the "current value" of the
4282     *    widget. Defaults to 'selected'; for some widget types it should be 'value'.
4283     * @param array<string,mixed> $values Map of field names (matching the 'legacy-name') to current field values.
4284     *   For checkboxes, the value is a bool that indicates the checked status of the checkbox.
4285     * @return array[]
4286     */
4287    public function getCheckboxesDefinition( $values ) {
4288        $checkboxes = [];
4289
4290        $user = $this->context->getUser();
4291        // don't show the minor edit checkbox if it's a new page or section
4292        if ( !$this->isNew && $this->permManager->userHasRight( $user, 'minoredit' ) ) {
4293            $checkboxes['wpMinoredit'] = [
4294                'id' => 'wpMinoredit',
4295                'label-message' => 'minoredit',
4296                // Uses messages: tooltip-minoredit, accesskey-minoredit
4297                'tooltip' => 'minoredit',
4298                'label-id' => 'mw-editpage-minoredit',
4299                'legacy-name' => 'minor',
4300                'default' => $values['minor'],
4301            ];
4302        }
4303
4304        if ( $user->isNamed() ) {
4305            $checkboxes = array_merge(
4306                $checkboxes,
4307                $this->getCheckboxesDefinitionForWatchlist( $values['watch'], $values['wpWatchlistExpiry'] ?? null )
4308            );
4309        }
4310
4311        $this->getHookRunner()->onEditPageGetCheckboxesDefinition( $this, $checkboxes );
4312
4313        return $checkboxes;
4314    }
4315
4316    /**
4317     * Get the watchthis and watchlistExpiry form field definitions.
4318     *
4319     * @param bool $watch
4320     * @param string $watchexpiry
4321     * @return array[]
4322     */
4323    private function getCheckboxesDefinitionForWatchlist( $watch, $watchexpiry ): array {
4324        $fieldDefs = [
4325            'wpWatchthis' => [
4326                'id' => 'wpWatchthis',
4327                'label-message' => 'watchthis',
4328                // Uses messages: tooltip-watch, accesskey-watch
4329                'tooltip' => 'watch',
4330                'label-id' => 'mw-editpage-watch',
4331                'legacy-name' => 'watch',
4332                'default' => $watch,
4333            ]
4334        ];
4335        if ( $this->watchlistExpiryEnabled ) {
4336            $watchedItem = $this->watchedItemStore->getWatchedItem( $this->getContext()->getUser(), $this->getTitle() );
4337            $expiryOptions = WatchAction::getExpiryOptions( $this->getContext(), $watchedItem );
4338            if ( $watchexpiry && in_array( $watchexpiry, $expiryOptions['options'] ) ) {
4339                $expiryOptions['default'] = $watchexpiry;
4340            }
4341            // Reformat the options to match what DropdownInputWidget wants.
4342            $options = [];
4343            foreach ( $expiryOptions['options'] as $label => $value ) {
4344                $options[] = [ 'data' => $value, 'label' => $label ];
4345            }
4346            $fieldDefs['wpWatchlistExpiry'] = [
4347                'id' => 'wpWatchlistExpiry',
4348                'label-message' => 'confirm-watch-label',
4349                // Uses messages: tooltip-watchlist-expiry, accesskey-watchlist-expiry
4350                'tooltip' => 'watchlist-expiry',
4351                'label-id' => 'mw-editpage-watchlist-expiry',
4352                'default' => $expiryOptions['default'],
4353                'value-attr' => 'value',
4354                'class' => DropdownInputWidget::class,
4355                'options' => $options,
4356                'invisibleLabel' => true,
4357            ];
4358        }
4359        return $fieldDefs;
4360    }
4361
4362    /**
4363     * Returns an array of fields for the edit form, including 'minor' and 'watch' checkboxes and
4364     * any other added by extensions. Despite the name, not only checkboxes are supported.
4365     *
4366     * @param int &$tabindex Current tabindex
4367     * @param array<string,mixed> $values Map of field names to current field values.
4368     *   For checkboxes, the value is a bool that indicates the checked status of the checkbox.
4369     * @return \OOUI\Element[] Associative array of string keys to \OOUI\Widget or \OOUI\Layout
4370     *  instances
4371     */
4372    public function getCheckboxesWidget( &$tabindex, $values ) {
4373        $checkboxes = [];
4374        $checkboxesDef = $this->getCheckboxesDefinition( $values );
4375
4376        foreach ( $checkboxesDef as $name => $options ) {
4377            $legacyName = $options['legacy-name'] ?? $name;
4378
4379            $title = null;
4380            $accesskey = null;
4381            if ( isset( $options['tooltip'] ) ) {
4382                $accesskey = $this->context->msg( "accesskey-{$options['tooltip']}" )->text();
4383                $title = Linker::titleAttrib( $options['tooltip'] );
4384            }
4385            if ( isset( $options['title-message'] ) ) {
4386                $title = $this->context->msg( $options['title-message'] )->text();
4387            }
4388            // Allow checkbox definitions to set their own class and value-attribute names.
4389            // See $this->getCheckboxesDefinition() for details.
4390            $className = $options['class'] ?? CheckboxInputWidget::class;
4391            $valueAttr = $options['value-attr'] ?? 'selected';
4392            $checkboxes[ $legacyName ] = new FieldLayout(
4393                new $className( [
4394                    'tabIndex' => ++$tabindex,
4395                    'accessKey' => $accesskey,
4396                    'id' => $options['id'] . 'Widget',
4397                    'inputId' => $options['id'],
4398                    'name' => $name,
4399                    $valueAttr => $options['default'],
4400                    'infusable' => true,
4401                    'options' => $options['options'] ?? null,
4402                ] ),
4403                [
4404                    'align' => 'inline',
4405                    'label' => new OOUI\HtmlSnippet( $this->context->msg( $options['label-message'] )->parse() ),
4406                    'title' => $title,
4407                    'id' => $options['label-id'] ?? null,
4408                    'invisibleLabel' => $options['invisibleLabel'] ?? null,
4409                ]
4410            );
4411        }
4412
4413        return $checkboxes;
4414    }
4415
4416    /**
4417     * Get the message key of the label for the button to save the page
4418     *
4419     * @return string
4420     */
4421    private function getSubmitButtonLabel(): string {
4422        $labelAsPublish =
4423            $this->context->getConfig()->get( MainConfigNames::EditSubmitButtonLabelPublish );
4424
4425        // Can't use $this->isNew as that's also true if we're adding a new section to an extant page
4426        $newPage = !$this->mTitle->exists();
4427
4428        if ( $labelAsPublish ) {
4429            $buttonLabelKey = $newPage ? 'publishpage' : 'publishchanges';
4430        } else {
4431            $buttonLabelKey = $newPage ? 'savearticle' : 'savechanges';
4432        }
4433
4434        return $buttonLabelKey;
4435    }
4436
4437    /**
4438     * Returns an array of html code of the following buttons:
4439     * save, diff and preview
4440     *
4441     * @param int &$tabindex Current tabindex
4442     *
4443     * @return string[] Strings or objects with a __toString() implementation. Usually an array of
4444     *  {@see ButtonInputWidget}, but EditPageBeforeEditButtons hook handlers might inject something
4445     *  else.
4446     */
4447    public function getEditButtons( &$tabindex ) {
4448        $buttons = [];
4449
4450        $labelAsPublish =
4451            $this->context->getConfig()->get( MainConfigNames::EditSubmitButtonLabelPublish );
4452
4453        $buttonLabel = $this->context->msg( $this->getSubmitButtonLabel() )->text();
4454        $buttonTooltip = $labelAsPublish ? 'publish' : 'save';
4455
4456        $buttons['save'] = new OOUI\ButtonInputWidget( [
4457            'name' => 'wpSave',
4458            'tabIndex' => ++$tabindex,
4459            'id' => 'wpSaveWidget',
4460            'inputId' => 'wpSave',
4461            // Support: IE 6 â€“ Use <input>, otherwise it can't distinguish which button was clicked
4462            'useInputTag' => true,
4463            'flags' => [ 'progressive', 'primary' ],
4464            'label' => $buttonLabel,
4465            'infusable' => true,
4466            'type' => 'submit',
4467            // Messages used: tooltip-save, tooltip-publish
4468            'title' => Linker::titleAttrib( $buttonTooltip ),
4469            // Messages used: accesskey-save, accesskey-publish
4470            'accessKey' => Linker::accesskey( $buttonTooltip ),
4471        ] );
4472
4473        $buttons['preview'] = new OOUI\ButtonInputWidget( [
4474            'name' => 'wpPreview',
4475            'tabIndex' => ++$tabindex,
4476            'id' => 'wpPreviewWidget',
4477            'inputId' => 'wpPreview',
4478            // Support: IE 6 â€“ Use <input>, otherwise it can't distinguish which button was clicked
4479            'useInputTag' => true,
4480            'label' => $this->context->msg( 'showpreview' )->text(),
4481            'infusable' => true,
4482            'type' => 'submit',
4483            // Allow previewing even when the form is in invalid state (T343585)
4484            'formNoValidate' => true,
4485            // Message used: tooltip-preview
4486            'title' => Linker::titleAttrib( 'preview' ),
4487            // Message used: accesskey-preview
4488            'accessKey' => Linker::accesskey( 'preview' ),
4489        ] );
4490
4491        $buttons['diff'] = new OOUI\ButtonInputWidget( [
4492            'name' => 'wpDiff',
4493            'tabIndex' => ++$tabindex,
4494            'id' => 'wpDiffWidget',
4495            'inputId' => 'wpDiff',
4496            // Support: IE 6 â€“ Use <input>, otherwise it can't distinguish which button was clicked
4497            'useInputTag' => true,
4498            'label' => $this->context->msg( 'showdiff' )->text(),
4499            'infusable' => true,
4500            'type' => 'submit',
4501            // Allow previewing even when the form is in invalid state (T343585)
4502            'formNoValidate' => true,
4503            // Message used: tooltip-diff
4504            'title' => Linker::titleAttrib( 'diff' ),
4505            // Message used: accesskey-diff
4506            'accessKey' => Linker::accesskey( 'diff' ),
4507        ] );
4508
4509        $this->getHookRunner()->onEditPageBeforeEditButtons( $this, $buttons, $tabindex );
4510
4511        return $buttons;
4512    }
4513
4514    /**
4515     * Creates a basic error page which informs the user that
4516     * they have attempted to edit a nonexistent section.
4517     */
4518    private function noSuchSectionPage(): void {
4519        $out = $this->context->getOutput();
4520        $out->prepareErrorPage();
4521        $out->setPageTitleMsg( $this->context->msg( 'nosuchsectiontitle' ) );
4522
4523        $res = $this->context->msg( 'nosuchsectiontext', $this->section )->parseAsBlock();
4524
4525        $this->getHookRunner()->onEditPageNoSuchSection( $this, $res );
4526        $out->addHTML( $res );
4527
4528        $out->returnToMain( false, $this->mTitle );
4529    }
4530
4531    /**
4532     * Show "your edit contains spam" page with your diff and text
4533     *
4534     * @param string|array|false $match Text (or array of texts) which triggered one or more filters
4535     */
4536    public function spamPageWithContent( $match = false ) {
4537        $this->textbox2 = $this->textbox1;
4538
4539        $out = $this->context->getOutput();
4540        $out->prepareErrorPage();
4541        $out->setPageTitleMsg( $this->context->msg( 'spamprotectiontitle' ) );
4542
4543        $spamText = $this->context->msg( 'spamprotectiontext' )->parseAsBlock();
4544
4545        if ( $match ) {
4546            if ( is_array( $match ) ) {
4547                $matchText = $this->context->getLanguage()->listToText( array_map( 'wfEscapeWikiText', $match ) );
4548            } else {
4549                $matchText = wfEscapeWikiText( $match );
4550            }
4551
4552            $spamText .= $this->context->msg( 'spamprotectionmatch' )
4553                ->params( $matchText )
4554                ->parseAsBlock();
4555        }
4556        $out->addHTML( Html::rawElement(
4557            'div',
4558            [ 'id' => 'spamprotected' ],
4559            $spamText
4560        ) );
4561
4562        $out->wrapWikiMsg( '<h2>$1</h2>', "yourdiff" );
4563        $this->showDiff();
4564
4565        $out->wrapWikiMsg( '<h2>$1</h2>', "yourtext" );
4566        $this->showTextbox( $this->textbox2, 'wpTextbox2', [ 'tabindex' => 6, 'readonly' ] );
4567
4568        $out->addReturnTo( $this->getContextTitle(), [ 'action' => 'edit' ] );
4569    }
4570
4571    private function addLongPageWarningHeader(): void {
4572        if ( $this->contentLength === false ) {
4573            $this->contentLength = strlen( $this->textbox1 );
4574        }
4575
4576        $out = $this->context->getOutput();
4577        $maxArticleSize = $this->context->getConfig()->get( MainConfigNames::MaxArticleSize );
4578        if ( $this->tooBig || $this->contentLength > $maxArticleSize * 1024 ) {
4579            $lang = $this->context->getLanguage();
4580            $out->wrapWikiMsg( "<div class='error' id='mw-edit-longpageerror'>\n$1\n</div>",
4581                [
4582                    'longpageerror',
4583                    $lang->formatNum( round( $this->contentLength / 1024, 3 ) ),
4584                    $lang->formatNum( $maxArticleSize )
4585                ]
4586            );
4587        } else {
4588            $longPageHint = $this->context->msg( 'longpage-hint' );
4589            if ( !$longPageHint->isDisabled() ) {
4590                $msgText = trim( $longPageHint->sizeParams( $this->contentLength )
4591                    ->params( $this->contentLength ) // Keep this unformatted for math inside message
4592                    ->text() );
4593                if ( $msgText !== '' && $msgText !== '-' ) {
4594                    $out->addWikiTextAsInterface( "<div id='mw-edit-longpage-hint'>\n$msgText\n</div>" );
4595                }
4596            }
4597        }
4598    }
4599
4600    private function addExplainConflictHeader(): void {
4601        $this->context->getOutput()->addHTML(
4602            $this->getEditConflictHelper()->getExplainHeader()
4603        );
4604    }
4605
4606    /**
4607     * Turns section name wikitext into anchors for use in HTTP redirects. Various
4608     * versions of Microsoft browsers misinterpret fragment encoding of Location: headers
4609     * resulting in mojibake in address bar. Redirect them to legacy section IDs,
4610     * if possible. All the other browsers get HTML5 if the wiki is configured for it, to
4611     * spread the new style links more efficiently.
4612     *
4613     * @param string $text
4614     * @return string
4615     */
4616    private function guessSectionName( $text ): string {
4617        // Detect Microsoft browsers
4618        $userAgent = $this->context->getRequest()->getHeader( 'User-Agent' );
4619        $parser = MediaWikiServices::getInstance()->getParser();
4620        if ( $userAgent && preg_match( '/MSIE|Edge/', $userAgent ) ) {
4621            // ...and redirect them to legacy encoding, if available
4622            return $parser->guessLegacySectionNameFromWikiText( $text );
4623        }
4624        // Meanwhile, real browsers get real anchors
4625        $name = $parser->guessSectionNameFromWikiText( $text );
4626        // With one little caveat: per T216029, fragments in HTTP redirects need to be urlencoded,
4627        // otherwise Chrome double-escapes the rest of the URL.
4628        return '#' . urlencode( mb_substr( $name, 1 ) );
4629    }
4630
4631    /**
4632     * @param callable $factory Factory function to create a {@see TextConflictHelper}
4633     * @since 1.31
4634     */
4635    public function setEditConflictHelperFactory( callable $factory ) {
4636        Assert::precondition( !$this->editConflictHelperFactory,
4637            'Can only have one extension that resolves edit conflicts' );
4638        $this->editConflictHelperFactory = $factory;
4639    }
4640
4641    private function getEditConflictHelper(): TextConflictHelper {
4642        if ( !$this->editConflictHelper ) {
4643            $label = $this->getSubmitButtonLabel();
4644            if ( $this->editConflictHelperFactory ) {
4645                $this->editConflictHelper = ( $this->editConflictHelperFactory )( $label );
4646            } else {
4647                $this->editConflictHelper = new TextConflictHelper(
4648                    $this->getTitle(),
4649                    $this->getContext()->getOutput(),
4650                    MediaWikiServices::getInstance()->getStatsFactory(),
4651                    $label,
4652                    MediaWikiServices::getInstance()->getContentHandlerFactory()
4653                );
4654            }
4655        }
4656        return $this->editConflictHelper;
4657    }
4658}
4659
4660/** @deprecated class alias since 1.40 */
4661class_alias( EditPage::class, 'EditPage' );