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