Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
3.32% covered (danger)
3.32%
19 / 573
0.00% covered (danger)
0.00%
0 / 27
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialUpload
3.32% covered (danger)
3.32%
19 / 572
0.00% covered (danger)
0.00%
0 / 27
25671.86
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 getRestriction
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addMessageBoxStyling
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 loadRequest
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
132
 isAsyncUpload
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 userCanExecute
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 execute
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
342
 showUploadStatus
0.00% covered (danger)
0.00%
0 / 69
0.00% covered (danger)
0.00%
0 / 1
420
 showUploadProgress
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
42
 showUploadForm
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getUploadForm
0.00% covered (danger)
0.00%
0 / 43
0.00% covered (danger)
0.00%
0 / 1
72
 showRecoverableUploadError
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
20
 showUploadWarning
0.00% covered (danger)
0.00%
0 / 79
0.00% covered (danger)
0.00%
0 / 1
462
 showUploadError
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 performUploadChecks
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
72
 getPageTextAndTags
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 processUpload
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
20
 processAsyncUpload
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
20
 getInitialPageText
79.17% covered (warning)
79.17%
19 / 24
0.00% covered (danger)
0.00%
0 / 1
8.58
 getWatchCheck
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
56
 processVerificationError
0.00% covered (danger)
0.00%
0 / 34
0.00% covered (danger)
0.00%
0 / 1
182
 unsaveUploadedFile
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 getExistsWarning
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
110
 getDupeWarning
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 rotationEnabled
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Specials;
8
9use MediaWiki\ChangeTags\ChangeTags;
10use MediaWiki\Config\Config;
11use MediaWiki\Exception\ErrorPageError;
12use MediaWiki\Exception\PermissionsError;
13use MediaWiki\Exception\UserBlockedError;
14use MediaWiki\FileRepo\File\File;
15use MediaWiki\FileRepo\File\LocalFile;
16use MediaWiki\FileRepo\LocalRepo;
17use MediaWiki\FileRepo\RepoGroup;
18use MediaWiki\Gallery\ImageGalleryBase;
19use MediaWiki\HookContainer\HookRunner;
20use MediaWiki\Html\Html;
21use MediaWiki\HTMLForm\HTMLForm;
22use MediaWiki\JobQueue\JobQueueGroup;
23use MediaWiki\JobQueue\Jobs\UploadFromUrlJob;
24use MediaWiki\Logger\LoggerFactory;
25use MediaWiki\Logging\LogEventsList;
26use MediaWiki\MainConfigNames;
27use MediaWiki\Media\BitmapHandler;
28use MediaWiki\MediaWikiServices;
29use MediaWiki\Page\WikiFilePage;
30use MediaWiki\Request\FauxRequest;
31use MediaWiki\Request\WebRequest;
32use MediaWiki\SpecialPage\SpecialPage;
33use MediaWiki\Specials\Forms\UploadForm;
34use MediaWiki\Status\Status;
35use MediaWiki\Title\NamespaceInfo;
36use MediaWiki\Title\Title;
37use MediaWiki\Upload\Exception\UploadStashException;
38use MediaWiki\Upload\UploadBase;
39use MediaWiki\Upload\UploadFromStash;
40use MediaWiki\User\Options\UserOptionsLookup;
41use MediaWiki\User\User;
42use MediaWiki\Watchlist\WatchlistManager;
43use Psr\Log\LoggerInterface;
44use UnexpectedValueException;
45
46/**
47 * Form for uploading media files.
48 *
49 * @ingroup SpecialPage
50 * @ingroup Upload
51 */
52class SpecialUpload extends SpecialPage {
53
54    private readonly LocalRepo $localRepo;
55    private readonly UserOptionsLookup $userOptionsLookup;
56    private readonly NamespaceInfo $nsInfo;
57    private readonly WatchlistManager $watchlistManager;
58    private readonly JobQueueGroup $jobQueueGroup;
59    private readonly LoggerInterface $log;
60
61    public function __construct(
62        ?RepoGroup $repoGroup = null,
63        ?UserOptionsLookup $userOptionsLookup = null,
64        ?NamespaceInfo $nsInfo = null,
65        ?WatchlistManager $watchlistManager = null,
66    ) {
67        parent::__construct( 'Upload' );
68        // This class is extended and therefor fallback to global state - T265300
69        $services = MediaWikiServices::getInstance();
70        $this->jobQueueGroup = $services->getJobQueueGroup();
71        $repoGroup ??= $services->getRepoGroup();
72        $this->localRepo = $repoGroup->getLocalRepo();
73        $this->userOptionsLookup = $userOptionsLookup ?? $services->getUserOptionsLookup();
74        $this->nsInfo = $nsInfo ?? $services->getNamespaceInfo();
75        $this->watchlistManager = $watchlistManager ?? $services->getWatchlistManager();
76        $this->log = LoggerFactory::getInstance( 'SpecialUpload' );
77    }
78
79    /** @inheritDoc */
80    public function getRestriction(): string {
81        return 'upload';
82    }
83
84    private function addMessageBoxStyling() {
85        $this->getOutput()->addModuleStyles( 'mediawiki.codex.messagebox.styles' );
86    }
87
88    /** @inheritDoc */
89    public function doesWrites() {
90        return true;
91    }
92
93    // Misc variables
94
95    /** @var WebRequest|FauxRequest The request this form is supposed to handle */
96    public $mRequest;
97    /** @var string */
98    public $mSourceType;
99
100    /** @var string The cache key to use to retrieve the status of your async upload */
101    public $mCacheKey;
102
103    /** @var UploadBase */
104    public $mUpload;
105
106    /** @var LocalFile */
107    public $mLocalFile;
108    /** @var bool */
109    public $mUploadClicked;
110
111    // User input variables from the "description" section
112
113    /** @var string The requested target file name */
114    public $mDesiredDestName;
115    /** @var string */
116    public $mComment;
117    /** @var string */
118    public $mLicense;
119
120    // User input variables from the root section
121
122    /** @var bool */
123    public $mIgnoreWarning;
124    /** @var bool */
125    public $mWatchthis;
126    /** @var string */
127    public $mCopyrightStatus;
128    /** @var string */
129    public $mCopyrightSource;
130
131    // Hidden variables
132
133    /** @var string */
134    public $mDestWarningAck;
135
136    /** @var bool The user followed an "overwrite this file" link */
137    public $mForReUpload;
138
139    /** @var bool The user clicked "Cancel and return to upload form" button */
140    public $mCancelUpload;
141    /** @var bool */
142    public $mTokenOk;
143
144    /** @var bool Subclasses can use this to determine whether a file was uploaded */
145    public $mUploadSuccessful = false;
146
147    /** @var string Raw html injection point for hooks not using HTMLForm */
148    public $uploadFormTextTop;
149    /** @var string Raw html injection point for hooks not using HTMLForm */
150    public $uploadFormTextAfterSummary;
151
152    /**
153     * Initialize instance variables from request and create an Upload handler
154     */
155    protected function loadRequest() {
156        $this->mRequest = $request = $this->getRequest();
157        $this->mSourceType = $request->getVal( 'wpSourceType', 'file' );
158        $this->mUpload = UploadBase::createFromRequest( $request );
159        $this->mUploadClicked = $request->wasPosted()
160            && ( $request->getCheck( 'wpUpload' )
161                || $request->getCheck( 'wpUploadIgnoreWarning' ) );
162
163        // Guess the desired name from the filename if not provided
164        $this->mDesiredDestName = $request->getText( 'wpDestFile' );
165        if ( !$this->mDesiredDestName && $request->getFileName( 'wpUploadFile' ) !== null ) {
166            $this->mDesiredDestName = $request->getFileName( 'wpUploadFile' );
167        }
168        $this->mLicense = $request->getText( 'wpLicense' );
169
170        $this->mDestWarningAck = $request->getText( 'wpDestFileWarningAck' );
171        $this->mIgnoreWarning = $request->getCheck( 'wpIgnoreWarning' )
172            || $request->getCheck( 'wpUploadIgnoreWarning' );
173        $this->mWatchthis = $request->getBool( 'wpWatchthis' ) && $this->getUser()->isRegistered();
174        $this->mCopyrightStatus = $request->getText( 'wpUploadCopyStatus' );
175        $this->mCopyrightSource = $request->getText( 'wpUploadSource' );
176
177        $this->mForReUpload = $request->getBool( 'wpForReUpload' ); // updating a file
178
179        $commentDefault = '';
180        $commentMsg = $this->msg( 'upload-default-description' )->inContentLanguage();
181        if ( !$this->mForReUpload && !$commentMsg->isDisabled() ) {
182            $commentDefault = $commentMsg->plain();
183        }
184        $this->mComment = $request->getText( 'wpUploadDescription', $commentDefault );
185
186        $this->mCancelUpload = $request->getCheck( 'wpCancelUpload' )
187            || $request->getCheck( 'wpReUpload' ); // b/w compat
188
189        // If it was posted check for the token (no remote POST'ing with user credentials)
190        $token = $request->getVal( 'wpEditToken' );
191        $this->mTokenOk = $this->getUser()->matchEditToken( $token );
192
193        // If this is an upload from Url and we're allowing async processing,
194        // check for the presence of the cache key parameter, or compute it. Else, it should be empty.
195        if ( $this->isAsyncUpload() ) {
196            $this->mCacheKey = \MediaWiki\Upload\UploadFromUrl::getCacheKeyFromRequest( $request );
197        } else {
198            $this->mCacheKey = '';
199        }
200
201        $this->uploadFormTextTop = '';
202        $this->uploadFormTextAfterSummary = '';
203    }
204
205    /**
206     * Check if the current request is an async upload by url request
207     *
208     * @return bool
209     */
210    protected function isAsyncUpload() {
211        return $this->mSourceType === 'url'
212            && $this->getConfig()->get( MainConfigNames::EnableAsyncUploads )
213            && $this->getConfig()->get( MainConfigNames::EnableAsyncUploadsByURL );
214    }
215
216    /**
217     * This page can be shown if uploading is enabled.
218     * Handle permission checking elsewhere in order to be able to show
219     * custom error messages.
220     *
221     * @param User $user
222     * @return bool
223     */
224    public function userCanExecute( User $user ) {
225        return UploadBase::isEnabled() && parent::userCanExecute( $user );
226    }
227
228    /**
229     * @param string|null $par
230     */
231    public function execute( $par ) {
232        $this->useTransactionalTimeLimit();
233
234        $this->setHeaders();
235        $this->outputHeader();
236
237        # Check uploading enabled
238        if ( !UploadBase::isEnabled() ) {
239            throw new ErrorPageError( 'uploaddisabled', 'uploaddisabledtext' );
240        }
241
242        $this->addHelpLink( 'Help:Managing files' );
243
244        # Check permissions
245        $user = $this->getUser();
246        $permissionRequired = UploadBase::isAllowed( $user );
247        if ( $permissionRequired !== true ) {
248            throw new PermissionsError( $permissionRequired );
249        }
250
251        # Check blocks
252        if ( $user->isBlockedFromUpload() ) {
253            throw new UserBlockedError(
254                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Block is checked and not null
255                $user->getBlock(),
256                $user,
257                $this->getLanguage(),
258                $this->getRequest()->getIP()
259            );
260        }
261
262        # Check whether we actually want to allow changing stuff
263        $this->checkReadOnly();
264
265        try {
266            $this->loadRequest();
267        } catch ( UploadStashException $e ) {
268            $this->showUploadError( $this->msg( 'upload-stash-error', $e->getMessageObject() )->escaped() );
269            return;
270        }
271
272        # Unsave the temporary file in case this was a cancelled upload
273        if ( $this->mCancelUpload && !$this->unsaveUploadedFile() ) {
274            # Something went wrong, so unsaveUploadedFile showed a warning
275            return;
276        }
277
278        # If we have a cache key, show the upload status.
279        if ( $this->mTokenOk && $this->mCacheKey !== '' ) {
280            if ( $this->mUpload && $this->mUploadClicked && !$this->mCancelUpload ) {
281                # If the user clicked the upload button, we need to process the upload
282                $this->processAsyncUpload();
283            } else {
284                # Show the upload status
285                $this->showUploadStatus( $user );
286            }
287        } elseif (
288            # Process upload or show a form
289            $this->mTokenOk && !$this->mCancelUpload &&
290            ( $this->mUpload && $this->mUploadClicked )
291        ) {
292            $this->processUpload();
293        } else {
294            # Backwards compatibility hook
295            if ( !$this->getHookRunner()->onUploadForm_initial( $this ) ) {
296                wfDebug( "Hook 'UploadForm:initial' broke output of the upload form" );
297
298                return;
299            }
300            $this->showUploadForm( $this->getUploadForm( '', $this->mUpload?->getStashFile()?->getFileKey() ) );
301        }
302
303        # Cleanup
304        if ( $this->mUpload ) {
305            $this->mUpload->cleanupTempFile();
306        }
307    }
308
309    /**
310     * Show the upload status
311     *
312     * @param User $user The owner of the upload
313     */
314    protected function showUploadStatus( $user ) {
315        // first, let's fetch the status from the main stash
316        $progress = UploadBase::getSessionStatus( $user, $this->mCacheKey );
317        if ( !$progress ) {
318            $progress = [ 'status' => Status::newFatal( 'invalid-cache-key' ) ];
319        }
320        $this->log->debug( 'Upload status: stage {stage}, result {result}', $progress );
321
322        $status = $progress['status'] ?? Status::newFatal( 'invalid-cache-key' );
323        $stage = $progress['stage'] ?? 'unknown';
324        $result = $progress['result'] ?? 'unknown';
325        switch ( $stage ) {
326            case 'publish':
327                switch ( $result ) {
328                    case 'Success':
329                        // The upload is done. Check the result and either show the form with the error
330                        // occurred, or redirect to the file itself
331                        // Success, redirect to description page
332                        $this->mUploadSuccessful = true;
333                        $this->getHookRunner()->onSpecialUploadComplete( $this );
334                        // Redirect to the destination URL, but purge the cache of the file description page first
335                        // TODO: understand why this is needed
336                        $title = Title::makeTitleSafe( NS_FILE, $this->mRequest->getText( 'wpDestFile' ) );
337                        if ( $title ) {
338                            $this->log->debug( 'Purging page', [ 'title' => $title->getText() ] );
339                            $page = new WikiFilePage( $title );
340                            $page->doPurge();
341                        }
342                        $this->getOutput()->redirect( $this->mRequest->getText( 'wpDestUrl' ) );
343                        break;
344                    case 'Warning':
345                        $this->showUploadWarning( UploadBase::unserializeWarnings( $progress['warnings'] ) );
346                        break;
347                    case 'Failure':
348                        $details = $status->getValue();
349                        // Verification failed.
350                        if ( is_array( $details ) && isset( $details['verification'] ) ) {
351                            $this->processVerificationError( $details['verification'] );
352                        } else {
353                            $this->showUploadError( $this->getOutput()->parseAsInterface(
354                                $status->getWikiText( false, false, $this->getLanguage() ) )
355                            );
356                        }
357                        break;
358                    case 'Poll':
359                        $this->showUploadProgress(
360                            [ 'active' => true, 'msg' => 'upload-progress-processing' ]
361                        );
362                        break;
363                    default:
364                        // unknown result, just show a generic error
365                        if ( $status->isOK() ) {
366                            $status = Status::newFatal( 'upload-progress-unknown' );
367                        }
368                        $this->showUploadError( $this->getOutput()->parseAsInterface(
369                            $status->getWikiText( false, false, $this->getLanguage() ) )
370                        );
371                        break;
372                }
373                break;
374            case 'queued':
375                // show stalled progress bar
376                $this->showUploadProgress( [ 'active' => false, 'msg' => 'upload-progress-queued' ] );
377                break;
378            case 'fetching':
379                switch ( $result ) {
380                    case 'Poll':
381                        // The file is being downloaded from a URL
382                        // TODO: show active progress bar saying we're downloading the file
383                        $this->showUploadProgress( [ 'active' => true, 'msg' => 'upload-progress-downloading' ] );
384                        break;
385                    case 'Failure':
386                        // downloading failed
387                        $this->showUploadError( $this->getOutput()->parseAsInterface(
388                            $status->getWikiText( false, false, $this->getLanguage() ) )
389                        );
390                        break;
391                    default:
392                        // unknown result, just show a generic error
393                        if ( $status->isOK() ) {
394                            $status = Status::newFatal( 'upload-progress-unknown' );
395                        }
396                        $this->showUploadError( $this->getOutput()->parseAsInterface(
397                            $status->getWikiText( false, false, $this->getLanguage() ) )
398                        );
399                        break;
400                }
401                break;
402            default:
403                // unknown status, just show a generic error
404                if ( $status->isOK() ) {
405                    $status = Status::newFatal( 'upload-progress-unknown' );
406                }
407                $statusmsg = $this->getOutput()->parseAsInterface(
408                    $status->getWikiText( false, false, $this->getLanguage() )
409                );
410                $message = '<h2>' . $this->msg( 'uploaderror' )->escaped() . '</h2>' . Html::errorBox( $statusmsg );
411                $this->addMessageBoxStyling();
412                $this->showUploadForm( $this->getUploadForm( $message ) );
413                break;
414        }
415    }
416
417    /**
418     * Show the upload progress in a form, with a refresh button
419     *
420     * This is used when the upload is being processed asynchronously. We're
421     * forced to use a refresh button because we need to poll the primary mainstash.
422     * See UploadBase::getSessionStatus for more information.
423     *
424     * @param array $options
425     * @return void
426     */
427    private function showUploadProgress( $options ) {
428        // $isActive = $options['active'] ?? false;
429        //$progressBarProperty = $isActive ? '' : 'disabled';
430        $message = $this->msg( $options['msg'] )->escaped();
431        $destUrl = $this->mRequest->getText( 'wpDestUrl', '' );
432        if ( !$destUrl && $this->mUpload ) {
433            if ( !$this->mLocalFile ) {
434                $this->mLocalFile = $this->mUpload->getLocalFile();
435            }
436            // This probably means the title is bad, so we can't get the URL
437            // but we need to wait for the job to execute.
438            if ( $this->mLocalFile === null ) {
439                $destUrl = '';
440            } else {
441                $destUrl = $this->mLocalFile->getTitle()->getFullURL();
442            }
443        }
444
445        $destName = $this->mDesiredDestName;
446        if ( !$destName ) {
447            $destName = $this->mRequest->getText( 'wpDestFile' );
448        }
449
450        $form = new HTMLForm( [
451            'CacheKey' => [
452                'type' => 'hidden',
453                'default' => $this->mCacheKey,
454            ],
455            'SourceType' => [
456                'type' => 'hidden',
457                'default' => $this->mSourceType,
458            ],
459            'DestUrl' => [
460                'type' => 'hidden',
461                'default' => $destUrl,
462            ],
463            'DestFile' => [
464                'type' => 'hidden',
465                'default' => $destName,
466            ],
467        ], $this->getContext(), 'uploadProgress' );
468        $form->setSubmitText( $this->msg( 'upload-refresh' )->text() );
469        // TODO: use codex, add a progress bar
470        //$preHtml = "<cdx-progress-bar aria--label='upload progressbar' $progressBarProperty />";
471        $preHtml = "<div id='upload-progress-message'>$message</div>";
472        $form->addPreHtml( $preHtml );
473        $form->setSubmitCallback( static fn ( $formData ) => true );
474        // Needed if we have warnings to show
475        $form->addHiddenFields( array_diff_key(
476            $this->mRequest->getValues(),
477            [
478                'title' => null,
479                'wpEditToken' => null,
480                'wpCacheKey' => null,
481                'wpSourceType' => null,
482                'wpDestUrl' => null,
483                'wpDestFile' => null,
484                'wpUpload' => null,
485                'wpUploadIgnoreWarning' => null,
486            ]
487        ) );
488        $form->prepareForm();
489        $this->getOutput()->addHTML( $form->getHTML( false ) );
490    }
491
492    /**
493     * Show the main upload form
494     *
495     * @param HTMLForm|string $form An HTMLForm instance or HTML string to show
496     */
497    protected function showUploadForm( $form ) {
498        if ( $form instanceof HTMLForm ) {
499            $form->show();
500        } else {
501            $this->getOutput()->addHTML( $form );
502        }
503    }
504
505    /**
506     * Get an UploadForm instance with title and text properly set.
507     *
508     * @param string $message HTML string to add to the form
509     * @param string|null $sessionKey Session key in case this is a stashed upload
510     * @param bool $hideIgnoreWarning Whether to hide "ignore warning" check box
511     * @return UploadForm
512     */
513    protected function getUploadForm( $message = '', $sessionKey = '', $hideIgnoreWarning = false ) {
514        # Initialize form
515        $form = new UploadForm(
516            [
517                'watch' => $this->getWatchCheck(),
518                'forreupload' => $this->mForReUpload,
519                'sessionkey' => $sessionKey,
520                'hideignorewarning' => $hideIgnoreWarning,
521                'destwarningack' => (bool)$this->mDestWarningAck,
522
523                'description' => $this->mComment,
524                'texttop' => $this->uploadFormTextTop,
525                'textaftersummary' => $this->uploadFormTextAfterSummary,
526                'destfile' => $this->mDesiredDestName,
527            ],
528            $this->getContext(),
529            $this->getLinkRenderer(),
530            $this->localRepo,
531            $this->getContentLanguage(),
532            $this->nsInfo,
533            $this->getHookContainer()
534        );
535        $form->setTitle( $this->getPageTitle() ); // Remove subpage
536
537        # Check the token, but only if necessary
538        if (
539            !$this->mTokenOk && !$this->mCancelUpload &&
540            ( $this->mUpload && $this->mUploadClicked )
541        ) {
542            $form->addPreHtml( $this->msg( 'session_fail_preview' )->parse() );
543        }
544
545        # Give a notice if the user is uploading a file that has been deleted or moved
546        # Note that this is independent from the message 'filewasdeleted'
547        $desiredTitleObj = Title::makeTitleSafe( NS_FILE, $this->mDesiredDestName );
548        $delNotice = ''; // empty by default
549        if ( $desiredTitleObj instanceof Title && !$desiredTitleObj->exists() ) {
550            LogEventsList::showLogExtract( $delNotice, [ 'delete', 'move' ],
551                $desiredTitleObj,
552                '', [ 'lim' => 10,
553                    'conds' => [ $this->localRepo->getReplicaDB()->expr( 'log_action', '!=', 'revision' ) ],
554                    'showIfEmpty' => false,
555                    'msgKey' => [ 'upload-recreate-warning' ] ]
556            );
557        }
558        $form->addPreHtml( $delNotice );
559
560        # Add text to form
561        $form->addPreHtml( '<div id="uploadtext">' .
562            $this->msg( 'uploadtext', [ $this->mDesiredDestName ] )->parseAsBlock() .
563            '</div>' );
564        # Add upload error message
565        $form->addPreHtml( $message );
566
567        # Add footer to form
568        $uploadFooter = $this->msg( 'uploadfooter' );
569        if ( !$uploadFooter->isDisabled() ) {
570            $form->addPostHtml( '<div id="mw-upload-footer-message">'
571                . $uploadFooter->parseAsBlock() . "</div>\n" );
572        }
573
574        return $form;
575    }
576
577    /**
578     * Stashes the upload and shows the main upload form.
579     *
580     * Note: only errors that can be handled by changing the name or
581     * description should be redirected here. It should be assumed that the
582     * file itself is sensible and has passed UploadBase::verifyFile. This
583     * essentially means that UploadBase::VERIFICATION_ERROR and
584     * UploadBase::EMPTY_FILE should not be passed here.
585     *
586     * @param string $message HTML message to be passed to mainUploadForm
587     */
588    protected function showRecoverableUploadError( $message ) {
589        $stashFile = $this->mUpload->getStashFile();
590        if ( !$stashFile ) {
591            $stashStatus = $this->mUpload->tryStashFile( $this->getUser() );
592            if ( $stashStatus->isGood() ) {
593                $stashFile = $stashStatus->getValue();
594            }
595        }
596        if ( $stashFile ) {
597            $sessionKey = $stashFile->getFileKey();
598            $uploadWarning = 'upload-tryagain';
599        } else {
600            $sessionKey = null;
601            $uploadWarning = 'upload-tryagain-nostash';
602        }
603        $message = '<h2>' . $this->msg( 'uploaderror' )->escaped() . '</h2>' .
604            Html::errorBox( $message );
605
606        $this->addMessageBoxStyling();
607        $form = $this->getUploadForm( $message, $sessionKey );
608        $form->setSubmitText( $this->msg( $uploadWarning )->text() );
609        $this->showUploadForm( $form );
610    }
611
612    /**
613     * Stashes the upload, shows the main form, but adds a "continue anyway button".
614     * Also checks whether there are actually warnings to display.
615     *
616     * @param array $warnings
617     * @return bool True if warnings were displayed, false if there are no
618     *   warnings and it should continue processing
619     */
620    protected function showUploadWarning( $warnings ) {
621        # If there are no warnings, or warnings we can ignore, return early.
622        # mDestWarningAck is set when some javascript has shown the warning
623        # to the user. mForReUpload is set when the user clicks the "upload a
624        # new version" link.
625        if ( !$warnings || ( count( $warnings ) == 1
626            && isset( $warnings['exists'] )
627            && ( $this->mDestWarningAck || $this->mForReUpload ) )
628        ) {
629            return false;
630        }
631
632        if ( $this->mUpload ) {
633            $stashFile = $this->mUpload->getStashFile();
634            if ( !$stashFile ) {
635                $stashStatus = $this->mUpload->tryStashFile( $this->getUser() );
636                if ( $stashStatus->isGood() ) {
637                    $stashFile = $stashStatus->getValue();
638                }
639            }
640            if ( $stashFile ) {
641                $sessionKey = $stashFile->getFileKey();
642                $uploadWarning = 'uploadwarning-text';
643            } else {
644                $sessionKey = null;
645                $uploadWarning = 'uploadwarning-text-nostash';
646            }
647        } else {
648            $sessionKey = null;
649            $uploadWarning = 'uploadwarning-text-nostash';
650        }
651
652        // Add styles for the warning, reused from the live preview
653        $this->getOutput()->addModuleStyles( 'mediawiki.special' );
654
655        $linkRenderer = $this->getLinkRenderer();
656        $warningHtml = '<h2>' . $this->msg( 'uploadwarning' )->escaped() . "</h2>\n"
657            . '<div class="mw-destfile-warning"><ul>';
658        foreach ( $warnings as $warning => $args ) {
659            if ( $warning == 'badfilename' ) {
660                $this->mDesiredDestName = Title::makeTitle( NS_FILE, $args )->getText();
661            }
662            if ( $warning == 'exists' ) {
663                $msg = "\t<li>" . self::getExistsWarning( $args ) . "</li>\n";
664            } elseif ( $warning == 'no-change' ) {
665                $file = $args;
666                $filename = $file->getTitle()->getPrefixedText();
667                $msg = "\t<li>" . $this->msg( 'fileexists-no-change', $filename )->parse() . "</li>\n";
668            } elseif ( $warning == 'duplicate-version' ) {
669                $file = $args[0];
670                $count = count( $args );
671                $filename = $file->getTitle()->getPrefixedText();
672                $message = $this->msg( 'fileexists-duplicate-version' )
673                    ->params( $filename )
674                    ->numParams( $count );
675                $msg = "\t<li>" . $message->parse() . "</li>\n";
676            } elseif ( $warning == 'was-deleted' ) {
677                # If the file existed before and was deleted, warn the user of this
678                $ltitle = SpecialPage::getTitleFor( 'Log' );
679                $llink = $linkRenderer->makeKnownLink(
680                    $ltitle,
681                    $this->msg( 'deletionlog' )->text(),
682                    [],
683                    [
684                        'type' => 'delete',
685                        'page' => Title::makeTitle( NS_FILE, $args )->getPrefixedText(),
686                    ]
687                );
688                $msg = "\t<li>" . $this->msg( 'filewasdeleted' )->rawParams( $llink )->parse() . "</li>\n";
689            } elseif ( $warning == 'duplicate' ) {
690                $msg = $this->getDupeWarning( $args );
691            } elseif ( $warning == 'duplicate-archive' ) {
692                if ( $args === '' ) {
693                    $msg = "\t<li>" . $this->msg( 'file-deleted-duplicate-notitle' )->parse()
694                        . "</li>\n";
695                } else {
696                    $msg = "\t<li>" . $this->msg( 'file-deleted-duplicate',
697                            Title::makeTitle( NS_FILE, $args )->getPrefixedText() )->parse()
698                        . "</li>\n";
699                }
700            } else {
701                if ( $args === true ) {
702                    $args = [];
703                } elseif ( !is_array( $args ) ) {
704                    $args = [ $args ];
705                }
706                $msg = "\t<li>" . $this->msg( $warning, $args )->parse() . "</li>\n";
707            }
708            $warningHtml .= $msg;
709        }
710        $warningHtml .= "</ul></div>\n";
711        $warningHtml .= $this->msg( $uploadWarning )->parseAsBlock();
712
713        $form = $this->getUploadForm( $warningHtml, $sessionKey, /* $hideIgnoreWarning */ true );
714        $form->setSubmitTextMsg( 'upload-tryagain' );
715        $form->addButton( [
716            'name' => 'wpUploadIgnoreWarning',
717            'value' => $this->msg( 'ignorewarning' )->text()
718        ] );
719        $form->addButton( [
720            'name' => 'wpCancelUpload',
721            'value' => $this->msg( 'reuploaddesc' )->text()
722        ] );
723
724        $this->showUploadForm( $form );
725
726        # Indicate that we showed a form
727        return true;
728    }
729
730    /**
731     * Show the upload form with error message, but do not stash the file.
732     *
733     * @param string $message HTML string
734     */
735    protected function showUploadError( $message ) {
736        $message = '<h2>' . $this->msg( 'uploadwarning' )->escaped() . '</h2>' .
737            Html::errorBox( $message );
738        $this->showUploadForm( $this->getUploadForm( $message ) );
739        $this->addMessageBoxStyling();
740    }
741
742    /**
743     * Common steps for processing uploads
744     *
745     * @param Status $fetchFileStatus
746     * @return bool
747     */
748    protected function performUploadChecks( $fetchFileStatus ): bool {
749        if ( !$fetchFileStatus->isOK() ) {
750            $this->showUploadError( $this->getOutput()->parseAsInterface(
751                $fetchFileStatus->getWikiText( false, false, $this->getLanguage() )
752            ) );
753
754            return false;
755        }
756        if ( !$this->getHookRunner()->onUploadForm_BeforeProcessing( $this ) ) {
757            wfDebug( "Hook 'UploadForm:BeforeProcessing' broke processing the file." );
758            // This code path is deprecated. If you want to break upload processing
759            // do so by hooking into the appropriate hooks in UploadBase::verifyUpload
760            // and UploadBase::verifyFile.
761            // If you use this hook to break uploading, the user will be returned
762            // an empty form with no error message whatsoever.
763            return false;
764        }
765
766        // Upload verification
767        // If this is an asynchronous upload-by-url, skip the verification
768        if ( $this->isAsyncUpload() ) {
769            return true;
770        }
771        $details = $this->mUpload->verifyUpload();
772        if ( $details['status'] != UploadBase::OK ) {
773            $this->processVerificationError( $details );
774
775            return false;
776        }
777
778        // Verify permissions for this title
779        $user = $this->getUser();
780        $status = $this->mUpload->authorizeUpload( $user );
781        if ( !$status->isGood() ) {
782            $this->showRecoverableUploadError(
783                $this->getOutput()->parseAsInterface(
784                    Status::wrap( $status )->getWikiText( false, false, $this->getLanguage() )
785                )
786            );
787
788            return false;
789        }
790
791        $this->mLocalFile = $this->mUpload->getLocalFile();
792
793        // Check warnings if necessary
794        if ( !$this->mIgnoreWarning ) {
795            $warnings = $this->mUpload->checkWarnings( $user );
796            if ( $this->showUploadWarning( $warnings ) ) {
797                return false;
798            }
799        }
800
801        return true;
802    }
803
804    /**
805     * Get the page text and tags for the upload
806     *
807     * @return array|null
808     */
809    protected function getPageTextAndTags() {
810        // Get the page text if this is not a reupload
811        if ( !$this->mForReUpload ) {
812            $pageText = self::getInitialPageText( $this->mComment, $this->mLicense,
813                $this->mCopyrightStatus, $this->mCopyrightSource,
814                $this->getConfig() );
815        } else {
816            $pageText = false;
817        }
818        $changeTags = $this->getRequest()->getVal( 'wpChangeTags' );
819        if ( $changeTags === null || $changeTags === '' ) {
820            $changeTags = [];
821        } else {
822            $changeTags = array_filter( array_map( 'trim', explode( ',', $changeTags ) ) );
823        }
824        if ( $changeTags ) {
825            $changeTagsStatus = ChangeTags::canAddTagsAccompanyingChange(
826                $changeTags, $this->getUser() );
827            if ( !$changeTagsStatus->isOK() ) {
828                $this->showUploadError( $this->getOutput()->parseAsInterface(
829                    $changeTagsStatus->getWikiText( false, false, $this->getLanguage() )
830                ) );
831
832                return null;
833            }
834        }
835        return [ $pageText, $changeTags ];
836    }
837
838    /**
839     * Do the upload.
840     * Checks are made in SpecialUpload::execute()
841     */
842    protected function processUpload() {
843        // Fetch the file if required
844        $status = $this->mUpload->fetchFile();
845        if ( !$this->performUploadChecks( $status ) ) {
846            return;
847        }
848        $user = $this->getUser();
849        $pageAndTags = $this->getPageTextAndTags();
850        if ( $pageAndTags === null ) {
851            return;
852        }
853        [ $pageText, $changeTags ] = $pageAndTags;
854
855        $status = $this->mUpload->performUpload(
856            $this->mComment,
857            $pageText,
858            $this->mWatchthis,
859            $user,
860            $changeTags
861        );
862
863        if ( !$status->isGood() ) {
864            $this->showRecoverableUploadError(
865                $this->getOutput()->parseAsInterface(
866                    $status->getWikiText( false, false, $this->getLanguage() )
867                )
868            );
869
870            return;
871        }
872
873        // Success, redirect to description page
874        $this->mUploadSuccessful = true;
875        $this->getHookRunner()->onSpecialUploadComplete( $this );
876        $this->getOutput()->redirect( $this->mLocalFile->getTitle()->getFullURL() );
877    }
878
879    /**
880     * Process an asynchronous upload
881     */
882    protected function processAsyncUpload() {
883        // Ensure the upload we're dealing with is an UploadFromUrl
884        if ( !$this->mUpload instanceof \MediaWiki\Upload\UploadFromUrl ) {
885            $this->showUploadError( $this->msg( 'uploaderror' )->escaped() );
886
887            return;
888        }
889        // check we can fetch the file
890        $status = $this->mUpload->canFetchFile();
891        if ( !$this->performUploadChecks( $status ) ) {
892            $this->log->debug( 'Upload failed verification: {error}', [ 'error' => $status ] );
893            return;
894        }
895
896        $pageAndTags = $this->getPageTextAndTags();
897        if ( $pageAndTags === null ) {
898            return;
899        }
900        [ $pageText, $changeTags ] = $pageAndTags;
901
902        // Create a new job to process the upload from url
903        $job = new UploadFromUrlJob(
904            [
905                'filename' => $this->mUpload->getDesiredDestName(),
906                'url' => $this->mUpload->getUrl(),
907                'comment' => $this->mComment,
908                'tags' => $changeTags,
909                'text' => $pageText,
910                'watch' => $this->mWatchthis,
911                'watchlistexpiry' => null,
912                'session' => $this->getContext()->exportSession(),
913                'reupload' => $this->mForReUpload,
914                'ignorewarnings' => $this->mIgnoreWarning,
915            ]
916        );
917        // Save the session status
918        $cacheKey = $job->getCacheKey();
919        UploadBase::setSessionStatus( $this->getUser(), $cacheKey, [
920            'status' => Status::newGood(),
921            'stage' => 'queued',
922            'result' => 'Poll'
923        ] );
924        $this->log->info( "Submitting UploadFromUrlJob for {filename}",
925            [ 'filename' => $this->mUpload->getDesiredDestName() ]
926        );
927        // Submit the job
928        $this->jobQueueGroup->push( $job );
929        // Show the upload status
930        $this->showUploadStatus( $this->getUser() );
931    }
932
933    /**
934     * Get the initial image page text based on a comment and optional file status information
935     * @param string $comment
936     * @param string $license
937     * @param string $copyStatus
938     * @param string $source
939     * @param Config|null $config Configuration object to load data from
940     * @return string
941     */
942    public static function getInitialPageText( $comment = '', $license = '',
943        $copyStatus = '', $source = '', ?Config $config = null
944    ) {
945        if ( $config === null ) {
946            wfDebug( __METHOD__ . ' called without a Config instance passed to it' );
947            $config = MediaWikiServices::getInstance()->getMainConfig();
948        }
949
950        $msg = [];
951        $forceUIMsgAsContentMsg = (array)$config->get( MainConfigNames::ForceUIMsgAsContentMsg );
952        /* These messages are transcluded into the actual text of the description page.
953         * Thus, forcing them as content messages makes the upload to produce an int: template
954         * instead of hardcoding it there in the uploader language.
955         */
956        foreach ( [ 'license-header', 'filedesc', 'filestatus', 'filesource' ] as $msgName ) {
957            if ( in_array( $msgName, $forceUIMsgAsContentMsg ) ) {
958                $msg[$msgName] = "{{int:$msgName}}";
959            } else {
960                $msg[$msgName] = wfMessage( $msgName )->inContentLanguage()->text();
961            }
962        }
963
964        $licenseText = '';
965        if ( $license !== '' ) {
966            $licenseText = '== ' . $msg['license-header'] . " ==\n{{" . $license . "}}\n";
967        }
968
969        $pageText = $comment . "\n";
970        $headerText = '== ' . $msg['filedesc'] . ' ==';
971        if ( $comment !== '' && !str_contains( $comment, $headerText ) ) {
972            // prepend header to page text unless it's already there (or there is no content)
973            $pageText = $headerText . "\n" . $pageText;
974        }
975
976        if ( $config->get( MainConfigNames::UseCopyrightUpload ) ) {
977            $pageText .= '== ' . $msg['filestatus'] . " ==\n" . $copyStatus . "\n";
978            $pageText .= $licenseText;
979            $pageText .= '== ' . $msg['filesource'] . " ==\n" . $source;
980        } else {
981            $pageText .= $licenseText;
982        }
983
984        // allow extensions to modify the content
985        ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
986            ->onUploadForm_getInitialPageText( $pageText, $msg, $config );
987
988        return $pageText;
989    }
990
991    /**
992     * See if we should check the 'watch this page' checkbox on the form
993     * based on the user's preferences and whether we're being asked
994     * to create a new file or update an existing one.
995     *
996     * In the case where 'watch edits' is off but 'watch creations' is on,
997     * we'll leave the box unchecked.
998     *
999     * Note that the page target can be changed *on the form*, so our check
1000     * state can get out of sync.
1001     * @return bool
1002     */
1003    protected function getWatchCheck() {
1004        $user = $this->getUser();
1005        if ( $this->userOptionsLookup->getBoolOption( $user, 'watchdefault' ) ) {
1006            // Watch all edits!
1007            return true;
1008        }
1009
1010        $desiredTitleObj = Title::makeTitleSafe( NS_FILE, $this->mDesiredDestName );
1011        if ( $desiredTitleObj instanceof Title &&
1012            $this->watchlistManager->isWatched( $user, $desiredTitleObj ) ) {
1013            // Already watched, don't change that
1014            return true;
1015        }
1016
1017        $local = $this->localRepo->newFile( $this->mDesiredDestName );
1018        if ( $local && $local->exists() ) {
1019            // We're uploading a new version of an existing file.
1020            // No creation, so don't watch it if we're not already.
1021            return false;
1022        } else {
1023            // New page should get watched if that's our option.
1024            return $this->userOptionsLookup->getBoolOption( $user, 'watchcreations' ) ||
1025                $this->userOptionsLookup->getBoolOption( $user, 'watchuploads' );
1026        }
1027    }
1028
1029    /**
1030     * Provides output to the user for a result of UploadBase::verifyUpload
1031     *
1032     * @param array $details Result of UploadBase::verifyUpload
1033     */
1034    protected function processVerificationError( $details ) {
1035        switch ( $details['status'] ) {
1036            /** Statuses that only require name changing */
1037            case UploadBase::MIN_LENGTH_PARTNAME:
1038                $this->showRecoverableUploadError( $this->msg( 'minlength1' )->escaped() );
1039                break;
1040            case UploadBase::ILLEGAL_FILENAME:
1041                $this->showRecoverableUploadError( $this->msg( 'illegalfilename',
1042                    $details['filtered'] )->parse() );
1043                break;
1044            case UploadBase::FILENAME_TOO_LONG:
1045                $this->showRecoverableUploadError( $this->msg( 'filename-toolong' )->escaped() );
1046                break;
1047            case UploadBase::FILETYPE_MISSING:
1048                $this->showRecoverableUploadError( $this->msg( 'filetype-missing' )->parse() );
1049                break;
1050            case UploadBase::WINDOWS_NONASCII_FILENAME:
1051                $this->showRecoverableUploadError( $this->msg( 'windows-nonascii-filename' )->parse() );
1052                break;
1053
1054            /** Statuses that require reuploading */
1055            case UploadBase::EMPTY_FILE:
1056                $this->showUploadError( $this->msg( 'emptyfile' )->escaped() );
1057                break;
1058            case UploadBase::FILE_TOO_LARGE:
1059                $this->showUploadError( $this->msg( 'largefileserver' )->escaped() );
1060                break;
1061            case UploadBase::FILETYPE_BADTYPE:
1062                $msg = $this->msg( 'filetype-banned-type' );
1063                if ( isset( $details['blacklistedExt'] ) ) {
1064                    $msg->params( $this->getLanguage()->commaList( $details['blacklistedExt'] ) );
1065                } else {
1066                    $msg->params( $details['finalExt'] );
1067                }
1068                $extensions =
1069                    array_unique( $this->getConfig()->get( MainConfigNames::FileExtensions ) );
1070                $msg->params( $this->getLanguage()->commaList( $extensions ),
1071                    count( $extensions ) );
1072
1073                // Add PLURAL support for the first parameter. This results
1074                // in a bit unlogical parameter sequence, but does not break
1075                // old translations
1076                if ( isset( $details['blacklistedExt'] ) ) {
1077                    $msg->params( count( $details['blacklistedExt'] ) );
1078                } else {
1079                    $msg->params( 1 );
1080                }
1081
1082                $this->showUploadError( $msg->parse() );
1083                break;
1084            case UploadBase::VERIFICATION_ERROR:
1085                unset( $details['status'] );
1086                $code = array_shift( $details['details'] );
1087                $this->showUploadError( $this->msg( $code, $details['details'] )->parse() );
1088                break;
1089            default:
1090                throw new UnexpectedValueException( __METHOD__ . ": Unknown value `{$details['status']}`" );
1091        }
1092    }
1093
1094    /**
1095     * Remove a temporarily kept file stashed by saveTempUploadedFile().
1096     *
1097     * @return bool Success
1098     */
1099    protected function unsaveUploadedFile() {
1100        if ( !( $this->mUpload instanceof UploadFromStash ) ) {
1101            return true;
1102        }
1103        $success = $this->mUpload->unsaveUploadedFile();
1104        if ( !$success ) {
1105            $this->getOutput()->showErrorPage(
1106                'internalerror',
1107                'filedeleteerror',
1108                [ $this->mUpload->getTempPath() ]
1109            );
1110
1111            return false;
1112        } else {
1113            return true;
1114        }
1115    }
1116
1117    /** Functions for formatting warnings */
1118
1119    /**
1120     * Formats a result of UploadBase::getExistsWarning as HTML
1121     * This check is static and can be done pre-upload via AJAX
1122     *
1123     * @param array|false $exists The result of UploadBase::getExistsWarning
1124     * @return string Empty string if there is no warning or an HTML fragment
1125     */
1126    public static function getExistsWarning( $exists ) {
1127        if ( !$exists ) {
1128            return '';
1129        }
1130
1131        $file = $exists['file'];
1132        if ( !$file instanceof File ) {
1133            // File deleted while showing entry from cache for async-url-upload
1134            // Or serialize error, see T409830
1135            return '';
1136        }
1137
1138        $filename = $file->getTitle()->getPrefixedText();
1139        $warnMsg = null;
1140
1141        if ( $exists['warning'] == 'exists' ) {
1142            // Exact match
1143            $warnMsg = wfMessage( 'fileexists', $filename );
1144        } elseif ( $exists['warning'] == 'page-exists' ) {
1145            // Page exists but file does not
1146            $warnMsg = wfMessage( 'filepageexists', $filename );
1147        } elseif ( $exists['warning'] == 'exists-normalized' ) {
1148            $warnMsg = wfMessage( 'fileexists-extension', $filename,
1149                $exists['normalizedFile']->getTitle()->getPrefixedText() );
1150        } elseif ( $exists['warning'] == 'thumb' ) {
1151            // Swapped argument order compared with other messages for backwards compatibility
1152            $warnMsg = wfMessage( 'fileexists-thumbnail-yes',
1153                $exists['thumbFile']->getTitle()->getPrefixedText(), $filename );
1154        } elseif ( $exists['warning'] == 'thumb-name' ) {
1155            // Image w/o '180px-' does not exists, but we do not like these filenames
1156            $name = $file->getName();
1157            $badPart = substr( $name, 0, strpos( $name, '-' ) + 1 );
1158            $warnMsg = wfMessage( 'file-thumbnail-no', $badPart );
1159        } elseif ( $exists['warning'] == 'bad-prefix' ) {
1160            $warnMsg = wfMessage( 'filename-bad-prefix', $exists['prefix'] );
1161        }
1162
1163        return $warnMsg ? $warnMsg->page( $file->getTitle() )->parse() : '';
1164    }
1165
1166    /**
1167     * Construct a warning and a gallery from an array of duplicate files.
1168     * @param array $dupes
1169     * @return string
1170     */
1171    public function getDupeWarning( $dupes ) {
1172        if ( !$dupes ) {
1173            return '';
1174        }
1175
1176        $gallery = ImageGalleryBase::factory( false, $this->getContext() );
1177        $gallery->setShowBytes( false );
1178        $gallery->setShowDimensions( false );
1179        foreach ( $dupes as $file ) {
1180            $gallery->add( $file->getTitle() );
1181        }
1182
1183        return '<li>' .
1184            $this->msg( 'file-exists-duplicate' )->numParams( count( $dupes ) )->parse() .
1185            $gallery->toHTML() . "</li>\n";
1186    }
1187
1188    /** @inheritDoc */
1189    protected function getGroupName() {
1190        return 'media';
1191    }
1192
1193    /**
1194     * Should we rotate images in the preview on Special:Upload.
1195     *
1196     * This controls js: mw.config.get( 'wgFileCanRotate' )
1197     *
1198     * @todo What about non-BitmapHandler handled files?
1199     * @return bool
1200     */
1201    public static function rotationEnabled() {
1202        $bitmapHandler = new BitmapHandler();
1203        return $bitmapHandler->autoRotateEnabled();
1204    }
1205}
1206
1207/**
1208 * Retain the old class name for backwards compatibility.
1209 * @deprecated since 1.41
1210 */
1211class_alias( SpecialUpload::class, 'SpecialUpload' );