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 / 573
0.00% covered (danger)
0.00%
0 / 27
25676.45
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 / 44
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                $this->getContext()
557            );
558        }
559        $form->addPreHtml( $delNotice );
560
561        # Add text to form
562        $form->addPreHtml( '<div id="uploadtext">' .
563            $this->msg( 'uploadtext', [ $this->mDesiredDestName ] )->parseAsBlock() .
564            '</div>' );
565        # Add upload error message
566        $form->addPreHtml( $message );
567
568        # Add footer to form
569        $uploadFooter = $this->msg( 'uploadfooter' );
570        if ( !$uploadFooter->isDisabled() ) {
571            $form->addPostHtml( '<div id="mw-upload-footer-message">'
572                . $uploadFooter->parseAsBlock() . "</div>\n" );
573        }
574
575        return $form;
576    }
577
578    /**
579     * Stashes the upload and shows the main upload form.
580     *
581     * Note: only errors that can be handled by changing the name or
582     * description should be redirected here. It should be assumed that the
583     * file itself is sensible and has passed UploadBase::verifyFile. This
584     * essentially means that UploadBase::VERIFICATION_ERROR and
585     * UploadBase::EMPTY_FILE should not be passed here.
586     *
587     * @param string $message HTML message to be passed to mainUploadForm
588     */
589    protected function showRecoverableUploadError( $message ) {
590        $stashFile = $this->mUpload->getStashFile();
591        if ( !$stashFile ) {
592            $stashStatus = $this->mUpload->tryStashFile( $this->getUser() );
593            if ( $stashStatus->isGood() ) {
594                $stashFile = $stashStatus->getValue();
595            }
596        }
597        if ( $stashFile ) {
598            $sessionKey = $stashFile->getFileKey();
599            $uploadWarning = 'upload-tryagain';
600        } else {
601            $sessionKey = null;
602            $uploadWarning = 'upload-tryagain-nostash';
603        }
604        $message = '<h2>' . $this->msg( 'uploaderror' )->escaped() . '</h2>' .
605            Html::errorBox( $message );
606
607        $this->addMessageBoxStyling();
608        $form = $this->getUploadForm( $message, $sessionKey );
609        $form->setSubmitText( $this->msg( $uploadWarning )->text() );
610        $this->showUploadForm( $form );
611    }
612
613    /**
614     * Stashes the upload, shows the main form, but adds a "continue anyway button".
615     * Also checks whether there are actually warnings to display.
616     *
617     * @param array $warnings
618     * @return bool True if warnings were displayed, false if there are no
619     *   warnings and it should continue processing
620     */
621    protected function showUploadWarning( $warnings ) {
622        # If there are no warnings, or warnings we can ignore, return early.
623        # mDestWarningAck is set when some javascript has shown the warning
624        # to the user. mForReUpload is set when the user clicks the "upload a
625        # new version" link.
626        if ( !$warnings || ( count( $warnings ) == 1
627            && isset( $warnings['exists'] )
628            && ( $this->mDestWarningAck || $this->mForReUpload ) )
629        ) {
630            return false;
631        }
632
633        if ( $this->mUpload ) {
634            $stashFile = $this->mUpload->getStashFile();
635            if ( !$stashFile ) {
636                $stashStatus = $this->mUpload->tryStashFile( $this->getUser() );
637                if ( $stashStatus->isGood() ) {
638                    $stashFile = $stashStatus->getValue();
639                }
640            }
641            if ( $stashFile ) {
642                $sessionKey = $stashFile->getFileKey();
643                $uploadWarning = 'uploadwarning-text';
644            } else {
645                $sessionKey = null;
646                $uploadWarning = 'uploadwarning-text-nostash';
647            }
648        } else {
649            $sessionKey = null;
650            $uploadWarning = 'uploadwarning-text-nostash';
651        }
652
653        // Add styles for the warning, reused from the live preview
654        $this->getOutput()->addModuleStyles( 'mediawiki.special' );
655
656        $linkRenderer = $this->getLinkRenderer();
657        $warningHtml = '<h2>' . $this->msg( 'uploadwarning' )->escaped() . "</h2>\n"
658            . '<div class="mw-destfile-warning"><ul>';
659        foreach ( $warnings as $warning => $args ) {
660            if ( $warning == 'badfilename' ) {
661                $this->mDesiredDestName = Title::makeTitle( NS_FILE, $args )->getText();
662            }
663            if ( $warning == 'exists' ) {
664                $msg = "\t<li>" . self::getExistsWarning( $args ) . "</li>\n";
665            } elseif ( $warning == 'no-change' ) {
666                $file = $args;
667                $filename = $file->getTitle()->getPrefixedText();
668                $msg = "\t<li>" . $this->msg( 'fileexists-no-change', $filename )->parse() . "</li>\n";
669            } elseif ( $warning == 'duplicate-version' ) {
670                $file = $args[0];
671                $count = count( $args );
672                $filename = $file->getTitle()->getPrefixedText();
673                $message = $this->msg( 'fileexists-duplicate-version' )
674                    ->params( $filename )
675                    ->numParams( $count );
676                $msg = "\t<li>" . $message->parse() . "</li>\n";
677            } elseif ( $warning == 'was-deleted' ) {
678                # If the file existed before and was deleted, warn the user of this
679                $ltitle = SpecialPage::getTitleFor( 'Log' );
680                $llink = $linkRenderer->makeKnownLink(
681                    $ltitle,
682                    $this->msg( 'deletionlog' )->text(),
683                    [],
684                    [
685                        'type' => 'delete',
686                        'page' => Title::makeTitle( NS_FILE, $args )->getPrefixedText(),
687                    ]
688                );
689                $msg = "\t<li>" . $this->msg( 'filewasdeleted' )->rawParams( $llink )->parse() . "</li>\n";
690            } elseif ( $warning == 'duplicate' ) {
691                $msg = $this->getDupeWarning( $args );
692            } elseif ( $warning == 'duplicate-archive' ) {
693                if ( $args === '' ) {
694                    $msg = "\t<li>" . $this->msg( 'file-deleted-duplicate-notitle' )->parse()
695                        . "</li>\n";
696                } else {
697                    $msg = "\t<li>" . $this->msg( 'file-deleted-duplicate',
698                            Title::makeTitle( NS_FILE, $args )->getPrefixedText() )->parse()
699                        . "</li>\n";
700                }
701            } else {
702                if ( $args === true ) {
703                    $args = [];
704                } elseif ( !is_array( $args ) ) {
705                    $args = [ $args ];
706                }
707                $msg = "\t<li>" . $this->msg( $warning, $args )->parse() . "</li>\n";
708            }
709            $warningHtml .= $msg;
710        }
711        $warningHtml .= "</ul></div>\n";
712        $warningHtml .= $this->msg( $uploadWarning )->parseAsBlock();
713
714        $form = $this->getUploadForm( $warningHtml, $sessionKey, /* $hideIgnoreWarning */ true );
715        $form->setSubmitTextMsg( 'upload-tryagain' );
716        $form->addButton( [
717            'name' => 'wpUploadIgnoreWarning',
718            'value' => $this->msg( 'ignorewarning' )->text()
719        ] );
720        $form->addButton( [
721            'name' => 'wpCancelUpload',
722            'value' => $this->msg( 'reuploaddesc' )->text()
723        ] );
724
725        $this->showUploadForm( $form );
726
727        # Indicate that we showed a form
728        return true;
729    }
730
731    /**
732     * Show the upload form with error message, but do not stash the file.
733     *
734     * @param string $message HTML string
735     */
736    protected function showUploadError( $message ) {
737        $message = '<h2>' . $this->msg( 'uploadwarning' )->escaped() . '</h2>' .
738            Html::errorBox( $message );
739        $this->showUploadForm( $this->getUploadForm( $message ) );
740        $this->addMessageBoxStyling();
741    }
742
743    /**
744     * Common steps for processing uploads
745     *
746     * @param Status $fetchFileStatus
747     * @return bool
748     */
749    protected function performUploadChecks( $fetchFileStatus ): bool {
750        if ( !$fetchFileStatus->isOK() ) {
751            $this->showUploadError( $this->getOutput()->parseAsInterface(
752                $fetchFileStatus->getWikiText( false, false, $this->getLanguage() )
753            ) );
754
755            return false;
756        }
757        if ( !$this->getHookRunner()->onUploadForm_BeforeProcessing( $this ) ) {
758            wfDebug( "Hook 'UploadForm:BeforeProcessing' broke processing the file." );
759            // This code path is deprecated. If you want to break upload processing
760            // do so by hooking into the appropriate hooks in UploadBase::verifyUpload
761            // and UploadBase::verifyFile.
762            // If you use this hook to break uploading, the user will be returned
763            // an empty form with no error message whatsoever.
764            return false;
765        }
766
767        // Upload verification
768        // If this is an asynchronous upload-by-url, skip the verification
769        if ( $this->isAsyncUpload() ) {
770            return true;
771        }
772        $details = $this->mUpload->verifyUpload();
773        if ( $details['status'] != UploadBase::OK ) {
774            $this->processVerificationError( $details );
775
776            return false;
777        }
778
779        // Verify permissions for this title
780        $user = $this->getUser();
781        $status = $this->mUpload->authorizeUpload( $user );
782        if ( !$status->isGood() ) {
783            $this->showRecoverableUploadError(
784                $this->getOutput()->parseAsInterface(
785                    Status::wrap( $status )->getWikiText( false, false, $this->getLanguage() )
786                )
787            );
788
789            return false;
790        }
791
792        $this->mLocalFile = $this->mUpload->getLocalFile();
793
794        // Check warnings if necessary
795        if ( !$this->mIgnoreWarning ) {
796            $warnings = $this->mUpload->checkWarnings( $user );
797            if ( $this->showUploadWarning( $warnings ) ) {
798                return false;
799            }
800        }
801
802        return true;
803    }
804
805    /**
806     * Get the page text and tags for the upload
807     *
808     * @return array|null
809     */
810    protected function getPageTextAndTags() {
811        // Get the page text if this is not a reupload
812        if ( !$this->mForReUpload ) {
813            $pageText = self::getInitialPageText( $this->mComment, $this->mLicense,
814                $this->mCopyrightStatus, $this->mCopyrightSource,
815                $this->getConfig() );
816        } else {
817            $pageText = false;
818        }
819        $changeTags = $this->getRequest()->getVal( 'wpChangeTags' );
820        if ( $changeTags === null || $changeTags === '' ) {
821            $changeTags = [];
822        } else {
823            $changeTags = array_filter( array_map( 'trim', explode( ',', $changeTags ) ) );
824        }
825        if ( $changeTags ) {
826            $changeTagsStatus = ChangeTags::canAddTagsAccompanyingChange(
827                $changeTags, $this->getUser() );
828            if ( !$changeTagsStatus->isOK() ) {
829                $this->showUploadError( $this->getOutput()->parseAsInterface(
830                    $changeTagsStatus->getWikiText( false, false, $this->getLanguage() )
831                ) );
832
833                return null;
834            }
835        }
836        return [ $pageText, $changeTags ];
837    }
838
839    /**
840     * Do the upload.
841     * Checks are made in SpecialUpload::execute()
842     */
843    protected function processUpload() {
844        // Fetch the file if required
845        $status = $this->mUpload->fetchFile();
846        if ( !$this->performUploadChecks( $status ) ) {
847            return;
848        }
849        $user = $this->getUser();
850        $pageAndTags = $this->getPageTextAndTags();
851        if ( $pageAndTags === null ) {
852            return;
853        }
854        [ $pageText, $changeTags ] = $pageAndTags;
855
856        $status = $this->mUpload->performUpload(
857            $this->mComment,
858            $pageText,
859            $this->mWatchthis,
860            $user,
861            $changeTags
862        );
863
864        if ( !$status->isGood() ) {
865            $this->showRecoverableUploadError(
866                $this->getOutput()->parseAsInterface(
867                    $status->getWikiText( false, false, $this->getLanguage() )
868                )
869            );
870
871            return;
872        }
873
874        // Success, redirect to description page
875        $this->mUploadSuccessful = true;
876        $this->getHookRunner()->onSpecialUploadComplete( $this );
877        $this->getOutput()->redirect( $this->mLocalFile->getTitle()->getFullURL() );
878    }
879
880    /**
881     * Process an asynchronous upload
882     */
883    protected function processAsyncUpload() {
884        // Ensure the upload we're dealing with is an UploadFromUrl
885        if ( !$this->mUpload instanceof \MediaWiki\Upload\UploadFromUrl ) {
886            $this->showUploadError( $this->msg( 'uploaderror' )->escaped() );
887
888            return;
889        }
890        // check we can fetch the file
891        $status = $this->mUpload->canFetchFile();
892        if ( !$this->performUploadChecks( $status ) ) {
893            $this->log->debug( 'Upload failed verification: {error}', [ 'error' => $status ] );
894            return;
895        }
896
897        $pageAndTags = $this->getPageTextAndTags();
898        if ( $pageAndTags === null ) {
899            return;
900        }
901        [ $pageText, $changeTags ] = $pageAndTags;
902
903        // Create a new job to process the upload from url
904        $job = new UploadFromUrlJob(
905            [
906                'filename' => $this->mUpload->getDesiredDestName(),
907                'url' => $this->mUpload->getUrl(),
908                'comment' => $this->mComment,
909                'tags' => $changeTags,
910                'text' => $pageText,
911                'watch' => $this->mWatchthis,
912                'watchlistexpiry' => null,
913                'session' => $this->getContext()->exportSession(),
914                'reupload' => $this->mForReUpload,
915                'ignorewarnings' => $this->mIgnoreWarning,
916            ]
917        );
918        // Save the session status
919        $cacheKey = $job->getCacheKey();
920        UploadBase::setSessionStatus( $this->getUser(), $cacheKey, [
921            'status' => Status::newGood(),
922            'stage' => 'queued',
923            'result' => 'Poll'
924        ] );
925        $this->log->info( "Submitting UploadFromUrlJob for {filename}",
926            [ 'filename' => $this->mUpload->getDesiredDestName() ]
927        );
928        // Submit the job
929        $this->jobQueueGroup->push( $job );
930        // Show the upload status
931        $this->showUploadStatus( $this->getUser() );
932    }
933
934    /**
935     * Get the initial image page text based on a comment and optional file status information
936     * @param string $comment
937     * @param string $license
938     * @param string $copyStatus
939     * @param string $source
940     * @param Config|null $config Configuration object to load data from
941     * @return string
942     */
943    public static function getInitialPageText( $comment = '', $license = '',
944        $copyStatus = '', $source = '', ?Config $config = null
945    ) {
946        if ( $config === null ) {
947            wfDebug( __METHOD__ . ' called without a Config instance passed to it' );
948            $config = MediaWikiServices::getInstance()->getMainConfig();
949        }
950
951        $msg = [];
952        $forceUIMsgAsContentMsg = (array)$config->get( MainConfigNames::ForceUIMsgAsContentMsg );
953        /* These messages are transcluded into the actual text of the description page.
954         * Thus, forcing them as content messages makes the upload to produce an int: template
955         * instead of hardcoding it there in the uploader language.
956         */
957        foreach ( [ 'license-header', 'filedesc', 'filestatus', 'filesource' ] as $msgName ) {
958            if ( in_array( $msgName, $forceUIMsgAsContentMsg ) ) {
959                $msg[$msgName] = "{{int:$msgName}}";
960            } else {
961                $msg[$msgName] = wfMessage( $msgName )->inContentLanguage()->text();
962            }
963        }
964
965        $licenseText = '';
966        if ( $license !== '' ) {
967            $licenseText = '== ' . $msg['license-header'] . " ==\n{{" . $license . "}}\n";
968        }
969
970        $pageText = $comment . "\n";
971        $headerText = '== ' . $msg['filedesc'] . ' ==';
972        if ( $comment !== '' && !str_contains( $comment, $headerText ) ) {
973            // prepend header to page text unless it's already there (or there is no content)
974            $pageText = $headerText . "\n" . $pageText;
975        }
976
977        if ( $config->get( MainConfigNames::UseCopyrightUpload ) ) {
978            $pageText .= '== ' . $msg['filestatus'] . " ==\n" . $copyStatus . "\n";
979            $pageText .= $licenseText;
980            $pageText .= '== ' . $msg['filesource'] . " ==\n" . $source;
981        } else {
982            $pageText .= $licenseText;
983        }
984
985        // allow extensions to modify the content
986        ( new HookRunner( MediaWikiServices::getInstance()->getHookContainer() ) )
987            ->onUploadForm_getInitialPageText( $pageText, $msg, $config );
988
989        return $pageText;
990    }
991
992    /**
993     * See if we should check the 'watch this page' checkbox on the form
994     * based on the user's preferences and whether we're being asked
995     * to create a new file or update an existing one.
996     *
997     * In the case where 'watch edits' is off but 'watch creations' is on,
998     * we'll leave the box unchecked.
999     *
1000     * Note that the page target can be changed *on the form*, so our check
1001     * state can get out of sync.
1002     * @return bool
1003     */
1004    protected function getWatchCheck() {
1005        $user = $this->getUser();
1006        if ( $this->userOptionsLookup->getBoolOption( $user, 'watchdefault' ) ) {
1007            // Watch all edits!
1008            return true;
1009        }
1010
1011        $desiredTitleObj = Title::makeTitleSafe( NS_FILE, $this->mDesiredDestName );
1012        if ( $desiredTitleObj instanceof Title &&
1013            $this->watchlistManager->isWatched( $user, $desiredTitleObj ) ) {
1014            // Already watched, don't change that
1015            return true;
1016        }
1017
1018        $local = $this->localRepo->newFile( $this->mDesiredDestName );
1019        if ( $local && $local->exists() ) {
1020            // We're uploading a new version of an existing file.
1021            // No creation, so don't watch it if we're not already.
1022            return false;
1023        } else {
1024            // New page should get watched if that's our option.
1025            return $this->userOptionsLookup->getBoolOption( $user, 'watchcreations' ) ||
1026                $this->userOptionsLookup->getBoolOption( $user, 'watchuploads' );
1027        }
1028    }
1029
1030    /**
1031     * Provides output to the user for a result of UploadBase::verifyUpload
1032     *
1033     * @param array $details Result of UploadBase::verifyUpload
1034     */
1035    protected function processVerificationError( $details ) {
1036        switch ( $details['status'] ) {
1037            /** Statuses that only require name changing */
1038            case UploadBase::MIN_LENGTH_PARTNAME:
1039                $this->showRecoverableUploadError( $this->msg( 'minlength1' )->escaped() );
1040                break;
1041            case UploadBase::ILLEGAL_FILENAME:
1042                $this->showRecoverableUploadError( $this->msg( 'illegalfilename',
1043                    $details['filtered'] )->parse() );
1044                break;
1045            case UploadBase::FILENAME_TOO_LONG:
1046                $this->showRecoverableUploadError( $this->msg( 'filename-toolong' )->escaped() );
1047                break;
1048            case UploadBase::FILETYPE_MISSING:
1049                $this->showRecoverableUploadError( $this->msg( 'filetype-missing' )->parse() );
1050                break;
1051            case UploadBase::WINDOWS_NONASCII_FILENAME:
1052                $this->showRecoverableUploadError( $this->msg( 'windows-nonascii-filename' )->parse() );
1053                break;
1054
1055            /** Statuses that require reuploading */
1056            case UploadBase::EMPTY_FILE:
1057                $this->showUploadError( $this->msg( 'emptyfile' )->escaped() );
1058                break;
1059            case UploadBase::FILE_TOO_LARGE:
1060                $this->showUploadError( $this->msg( 'largefileserver' )->escaped() );
1061                break;
1062            case UploadBase::FILETYPE_BADTYPE:
1063                $msg = $this->msg( 'filetype-banned-type' );
1064                if ( isset( $details['blacklistedExt'] ) ) {
1065                    $msg->params( $this->getLanguage()->commaList( $details['blacklistedExt'] ) );
1066                } else {
1067                    $msg->params( $details['finalExt'] );
1068                }
1069                $extensions =
1070                    array_unique( $this->getConfig()->get( MainConfigNames::FileExtensions ) );
1071                $msg->params( $this->getLanguage()->commaList( $extensions ),
1072                    count( $extensions ) );
1073
1074                // Add PLURAL support for the first parameter. This results
1075                // in a bit unlogical parameter sequence, but does not break
1076                // old translations
1077                if ( isset( $details['blacklistedExt'] ) ) {
1078                    $msg->params( count( $details['blacklistedExt'] ) );
1079                } else {
1080                    $msg->params( 1 );
1081                }
1082
1083                $this->showUploadError( $msg->parse() );
1084                break;
1085            case UploadBase::VERIFICATION_ERROR:
1086                unset( $details['status'] );
1087                $code = array_shift( $details['details'] );
1088                $this->showUploadError( $this->msg( $code, $details['details'] )->parse() );
1089                break;
1090            default:
1091                throw new UnexpectedValueException( __METHOD__ . ": Unknown value `{$details['status']}`" );
1092        }
1093    }
1094
1095    /**
1096     * Remove a temporarily kept file stashed by saveTempUploadedFile().
1097     *
1098     * @return bool Success
1099     */
1100    protected function unsaveUploadedFile() {
1101        if ( !( $this->mUpload instanceof UploadFromStash ) ) {
1102            return true;
1103        }
1104        $success = $this->mUpload->unsaveUploadedFile();
1105        if ( !$success ) {
1106            $this->getOutput()->showErrorPage(
1107                'internalerror',
1108                'filedeleteerror',
1109                [ $this->mUpload->getTempPath() ]
1110            );
1111
1112            return false;
1113        } else {
1114            return true;
1115        }
1116    }
1117
1118    /** Functions for formatting warnings */
1119
1120    /**
1121     * Formats a result of UploadBase::getExistsWarning as HTML
1122     * This check is static and can be done pre-upload via AJAX
1123     *
1124     * @param array|false $exists The result of UploadBase::getExistsWarning
1125     * @return string Empty string if there is no warning or an HTML fragment
1126     */
1127    public static function getExistsWarning( $exists ) {
1128        if ( !$exists ) {
1129            return '';
1130        }
1131
1132        $file = $exists['file'];
1133        if ( !$file instanceof File ) {
1134            // File deleted while showing entry from cache for async-url-upload
1135            // Or serialize error, see T409830
1136            return '';
1137        }
1138
1139        $filename = $file->getTitle()->getPrefixedText();
1140        $warnMsg = null;
1141
1142        if ( $exists['warning'] == 'exists' ) {
1143            // Exact match
1144            $warnMsg = wfMessage( 'fileexists', $filename );
1145        } elseif ( $exists['warning'] == 'page-exists' ) {
1146            // Page exists but file does not
1147            $warnMsg = wfMessage( 'filepageexists', $filename );
1148        } elseif ( $exists['warning'] == 'exists-normalized' ) {
1149            $warnMsg = wfMessage( 'fileexists-extension', $filename,
1150                $exists['normalizedFile']->getTitle()->getPrefixedText() );
1151        } elseif ( $exists['warning'] == 'thumb' ) {
1152            // Swapped argument order compared with other messages for backwards compatibility
1153            $warnMsg = wfMessage( 'fileexists-thumbnail-yes',
1154                $exists['thumbFile']->getTitle()->getPrefixedText(), $filename );
1155        } elseif ( $exists['warning'] == 'thumb-name' ) {
1156            // Image w/o '180px-' does not exists, but we do not like these filenames
1157            $name = $file->getName();
1158            $badPart = substr( $name, 0, strpos( $name, '-' ) + 1 );
1159            $warnMsg = wfMessage( 'file-thumbnail-no', $badPart );
1160        } elseif ( $exists['warning'] == 'bad-prefix' ) {
1161            $warnMsg = wfMessage( 'filename-bad-prefix', $exists['prefix'] );
1162        }
1163
1164        return $warnMsg ? $warnMsg->page( $file->getTitle() )->parse() : '';
1165    }
1166
1167    /**
1168     * Construct a warning and a gallery from an array of duplicate files.
1169     * @param array $dupes
1170     * @return string
1171     */
1172    public function getDupeWarning( $dupes ) {
1173        if ( !$dupes ) {
1174            return '';
1175        }
1176
1177        $gallery = ImageGalleryBase::factory( false, $this->getContext() );
1178        $gallery->setShowBytes( false );
1179        $gallery->setShowDimensions( false );
1180        foreach ( $dupes as $file ) {
1181            $gallery->add( $file->getTitle() );
1182        }
1183
1184        return '<li>' .
1185            $this->msg( 'file-exists-duplicate' )->numParams( count( $dupes ) )->parse() .
1186            $gallery->toHTML() . "</li>\n";
1187    }
1188
1189    /** @inheritDoc */
1190    protected function getGroupName() {
1191        return 'media';
1192    }
1193
1194    /**
1195     * Should we rotate images in the preview on Special:Upload.
1196     *
1197     * This controls js: mw.config.get( 'wgFileCanRotate' )
1198     *
1199     * @todo What about non-BitmapHandler handled files?
1200     * @return bool
1201     */
1202    public static function rotationEnabled() {
1203        $bitmapHandler = new BitmapHandler();
1204        return $bitmapHandler->autoRotateEnabled();
1205    }
1206}
1207
1208// @codeCoverageIgnoreStart
1209/**
1210 * Retain the old class name for backwards compatibility.
1211 * @deprecated since 1.41
1212 */
1213class_alias( SpecialUpload::class, 'SpecialUpload' );
1214// @codeCoverageIgnoreEnd