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