Completed
Branch master (939199)
by
unknown
39:35
created

includes/api/ApiUpload.php (3 issues)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
/**
3
 *
4
 *
5
 * Created on Aug 21, 2008
6
 *
7
 * Copyright © 2008 - 2010 Bryan Tong Minh <[email protected]>
8
 *
9
 * This program is free software; you can redistribute it and/or modify
10
 * it under the terms of the GNU General Public License as published by
11
 * the Free Software Foundation; either version 2 of the License, or
12
 * (at your option) any later version.
13
 *
14
 * This program is distributed in the hope that it will be useful,
15
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
 * GNU General Public License for more details.
18
 *
19
 * You should have received a copy of the GNU General Public License along
20
 * with this program; if not, write to the Free Software Foundation, Inc.,
21
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
22
 * http://www.gnu.org/copyleft/gpl.html
23
 *
24
 * @file
25
 */
26
27
/**
28
 * @ingroup API
29
 */
30
class ApiUpload extends ApiBase {
31
	/** @var UploadBase|UploadFromChunks */
32
	protected $mUpload = null;
33
34
	protected $mParams;
35
36
	public function execute() {
37
		// Check whether upload is enabled
38
		if ( !UploadBase::isEnabled() ) {
39
			$this->dieUsageMsg( 'uploaddisabled' );
40
		}
41
42
		$user = $this->getUser();
43
44
		// Parameter handling
45
		$this->mParams = $this->extractRequestParams();
46
		$request = $this->getMain()->getRequest();
47
		// Check if async mode is actually supported (jobs done in cli mode)
48
		$this->mParams['async'] = ( $this->mParams['async'] &&
49
			$this->getConfig()->get( 'EnableAsyncUploads' ) );
50
		// Add the uploaded file to the params array
51
		$this->mParams['file'] = $request->getFileName( 'file' );
52
		$this->mParams['chunk'] = $request->getFileName( 'chunk' );
53
54
		// Copy the session key to the file key, for backward compatibility.
55
		if ( !$this->mParams['filekey'] && $this->mParams['sessionkey'] ) {
56
			$this->mParams['filekey'] = $this->mParams['sessionkey'];
57
		}
58
59
		// Select an upload module
60
		try {
61
			if ( !$this->selectUploadModule() ) {
62
				return; // not a true upload, but a status request or similar
63
			} elseif ( !isset( $this->mUpload ) ) {
64
				$this->dieUsage( 'No upload module set', 'nomodule' );
65
			}
66
		} catch ( UploadStashException $e ) { // XXX: don't spam exception log
67
			list( $msg, $code ) = $this->handleStashException( get_class( $e ), $e->getMessage() );
68
			$this->dieUsage( $msg, $code );
69
		}
70
71
		// First check permission to upload
72
		$this->checkPermissions( $user );
73
74
		// Fetch the file (usually a no-op)
75
		/** @var $status Status */
76
		$status = $this->mUpload->fetchFile();
77
		if ( !$status->isGood() ) {
78
			$errors = $status->getErrorsArray();
79
			$error = array_shift( $errors[0] );
80
			$this->dieUsage( 'Error fetching file from remote source', $error, 0, $errors[0] );
81
		}
82
83
		// Check if the uploaded file is sane
84
		if ( $this->mParams['chunk'] ) {
85
			$maxSize = UploadBase::getMaxUploadSize();
86
			if ( $this->mParams['filesize'] > $maxSize ) {
87
				$this->dieUsage( 'The file you submitted was too large', 'file-too-large' );
88
			}
89
			if ( !$this->mUpload->getTitle() ) {
90
				$this->dieUsage( 'Invalid file title supplied', 'internal-error' );
91
			}
92
		} elseif ( $this->mParams['async'] && $this->mParams['filekey'] ) {
93
			// defer verification to background process
94
		} else {
95
			wfDebug( __METHOD__ . " about to verify\n" );
96
			$this->verifyUpload();
97
		}
98
99
		// Check if the user has the rights to modify or overwrite the requested title
100
		// (This check is irrelevant if stashing is already requested, since the errors
101
		//  can always be fixed by changing the title)
102
		if ( !$this->mParams['stash'] ) {
103
			$permErrors = $this->mUpload->verifyTitlePermissions( $user );
104
			if ( $permErrors !== true ) {
105
				$this->dieRecoverableError( $permErrors[0], 'filename' );
106
			}
107
		}
108
109
		// Get the result based on the current upload context:
110
		try {
111
			$result = $this->getContextResult();
112
		} catch ( UploadStashException $e ) { // XXX: don't spam exception log
113
			list( $msg, $code ) = $this->handleStashException( get_class( $e ), $e->getMessage() );
114
			$this->dieUsage( $msg, $code );
115
		}
116
		$this->getResult()->addValue( null, $this->getModuleName(), $result );
117
118
		// Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
119
		// so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
120
		if ( $result['result'] === 'Success' ) {
121
			$imageinfo = $this->mUpload->getImageInfo( $this->getResult() );
122
			$this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
123
		}
124
125
		// Cleanup any temporary mess
126
		$this->mUpload->cleanupTempFile();
127
	}
128
129
	/**
130
	 * Get an upload result based on upload context
131
	 * @return array
132
	 */
133
	private function getContextResult() {
134
		$warnings = $this->getApiWarnings();
135
		if ( $warnings && !$this->mParams['ignorewarnings'] ) {
136
			// Get warnings formatted in result array format
137
			return $this->getWarningsResult( $warnings );
138
		} elseif ( $this->mParams['chunk'] ) {
139
			// Add chunk, and get result
140
			return $this->getChunkResult( $warnings );
141
		} elseif ( $this->mParams['stash'] ) {
142
			// Stash the file and get stash result
143
			return $this->getStashResult( $warnings );
144
		}
145
146
		// Check throttle after we've handled warnings
147
		if ( UploadBase::isThrottled( $this->getUser() )
148
		) {
149
			$this->dieUsageMsg( 'actionthrottledtext' );
150
		}
151
152
		// This is the most common case -- a normal upload with no warnings
153
		// performUpload will return a formatted properly for the API with status
154
		return $this->performUpload( $warnings );
155
	}
156
157
	/**
158
	 * Get Stash Result, throws an exception if the file could not be stashed.
159
	 * @param array $warnings Array of Api upload warnings
160
	 * @return array
161
	 */
162
	private function getStashResult( $warnings ) {
163
		$result = [];
164
		$result['result'] = 'Success';
165
		if ( $warnings && count( $warnings ) > 0 ) {
166
			$result['warnings'] = $warnings;
167
		}
168
		// Some uploads can request they be stashed, so as not to publish them immediately.
169
		// In this case, a failure to stash ought to be fatal
170
		$this->performStash( 'critical', $result );
171
172
		return $result;
173
	}
174
175
	/**
176
	 * Get Warnings Result
177
	 * @param array $warnings Array of Api upload warnings
178
	 * @return array
179
	 */
180
	private function getWarningsResult( $warnings ) {
181
		$result = [];
182
		$result['result'] = 'Warning';
183
		$result['warnings'] = $warnings;
184
		// in case the warnings can be fixed with some further user action, let's stash this upload
185
		// and return a key they can use to restart it
186
		$this->performStash( 'optional', $result );
187
188
		return $result;
189
	}
190
191
	/**
192
	 * Get the result of a chunk upload.
193
	 * @param array $warnings Array of Api upload warnings
194
	 * @return array
195
	 */
196
	private function getChunkResult( $warnings ) {
197
		$result = [];
198
199
		if ( $warnings && count( $warnings ) > 0 ) {
200
			$result['warnings'] = $warnings;
201
		}
202
203
		$request = $this->getMain()->getRequest();
204
		$chunkPath = $request->getFileTempname( 'chunk' );
205
		$chunkSize = $request->getUpload( 'chunk' )->getSize();
206
		$totalSoFar = $this->mParams['offset'] + $chunkSize;
207
		$minChunkSize = $this->getConfig()->get( 'MinUploadChunkSize' );
208
209
		// Sanity check sizing
210
		if ( $totalSoFar > $this->mParams['filesize'] ) {
211
			$this->dieUsage(
212
				'Offset plus current chunk is greater than claimed file size', 'invalid-chunk'
213
			);
214
		}
215
216
		// Enforce minimum chunk size
217
		if ( $totalSoFar != $this->mParams['filesize'] && $chunkSize < $minChunkSize ) {
218
			$this->dieUsage(
219
				"Minimum chunk size is $minChunkSize bytes for non-final chunks", 'chunk-too-small'
220
			);
221
		}
222
223
		if ( $this->mParams['offset'] == 0 ) {
224
			$filekey = $this->performStash( 'critical' );
225
		} else {
226
			$filekey = $this->mParams['filekey'];
227
228
			// Don't allow further uploads to an already-completed session
229
			$progress = UploadBase::getSessionStatus( $this->getUser(), $filekey );
230
			if ( !$progress ) {
231
				// Probably can't get here, but check anyway just in case
232
				$this->dieUsage( 'No chunked upload session with this key', 'stashfailed' );
233
			} elseif ( $progress['result'] !== 'Continue' || $progress['stage'] !== 'uploading' ) {
234
				$this->dieUsage(
235
					'Chunked upload is already completed, check status for details', 'stashfailed'
236
				);
237
			}
238
239
			$status = $this->mUpload->addChunk(
240
				$chunkPath, $chunkSize, $this->mParams['offset'] );
241
			if ( !$status->isGood() ) {
242
				$extradata = [
243
					'offset' => $this->mUpload->getOffset(),
244
				];
245
246
				$this->dieStatusWithCode( $status, 'stashfailed', $extradata );
247
			}
248
		}
249
250
		// Check we added the last chunk:
251
		if ( $totalSoFar == $this->mParams['filesize'] ) {
252
			if ( $this->mParams['async'] ) {
253
				UploadBase::setSessionStatus(
254
					$this->getUser(),
255
					$filekey,
256
					[ 'result' => 'Poll',
257
						'stage' => 'queued', 'status' => Status::newGood() ]
258
				);
259
				JobQueueGroup::singleton()->push( new AssembleUploadChunksJob(
260
					Title::makeTitle( NS_FILE, $filekey ),
261
					[
262
						'filename' => $this->mParams['filename'],
263
						'filekey' => $filekey,
264
						'session' => $this->getContext()->exportSession()
265
					]
266
				) );
267
				$result['result'] = 'Poll';
268
				$result['stage'] = 'queued';
269
			} else {
270
				$status = $this->mUpload->concatenateChunks();
271
				if ( !$status->isGood() ) {
272
					UploadBase::setSessionStatus(
273
						$this->getUser(),
274
						$filekey,
275
						[ 'result' => 'Failure', 'stage' => 'assembling', 'status' => $status ]
276
					);
277
					$this->dieStatusWithCode( $status, 'stashfailed' );
278
				}
279
280
				// We can only get warnings like 'duplicate' after concatenating the chunks
281
				$warnings = $this->getApiWarnings();
282
				if ( $warnings ) {
283
					$result['warnings'] = $warnings;
284
				}
285
286
				// The fully concatenated file has a new filekey. So remove
287
				// the old filekey and fetch the new one.
288
				UploadBase::setSessionStatus( $this->getUser(), $filekey, false );
289
				$this->mUpload->stash->removeFile( $filekey );
290
				$filekey = $this->mUpload->getStashFile()->getFileKey();
291
292
				$result['result'] = 'Success';
293
			}
294
		} else {
295
			UploadBase::setSessionStatus(
296
				$this->getUser(),
297
				$filekey,
298
				[
299
					'result' => 'Continue',
300
					'stage' => 'uploading',
301
					'offset' => $totalSoFar,
302
					'status' => Status::newGood(),
303
				]
304
			);
305
			$result['result'] = 'Continue';
306
			$result['offset'] = $totalSoFar;
307
		}
308
309
		$result['filekey'] = $filekey;
310
311
		return $result;
312
	}
313
314
	/**
315
	 * Stash the file and add the file key, or error information if it fails, to the data.
316
	 *
317
	 * @param string $failureMode What to do on failure to stash:
318
	 *   - When 'critical', use dieStatus() to produce an error response and throw an exception.
319
	 *     Use this when stashing the file was the primary purpose of the API request.
320
	 *   - When 'optional', only add a 'stashfailed' key to the data and return null.
321
	 *     Use this when some error happened for a non-stash upload and we're stashing the file
322
	 *     only to save the client the trouble of re-uploading it.
323
	 * @param array &$data API result to which to add the information
324
	 * @return string|null File key
325
	 */
326
	private function performStash( $failureMode, &$data = null ) {
327
		$isPartial = (bool)$this->mParams['chunk'];
328
		try {
329
			$status = $this->mUpload->tryStashFile( $this->getUser(), $isPartial );
330
331
			if ( $status->isGood() && !$status->getValue() ) {
332
				// Not actually a 'good' status...
333
				$status->fatal( new ApiRawMessage( 'Invalid stashed file', 'stashfailed' ) );
334
			}
335
		} catch ( Exception $e ) {
336
			$debugMessage = 'Stashing temporary file failed: ' . get_class( $e ) . ' ' . $e->getMessage();
337
			wfDebug( __METHOD__ . ' ' . $debugMessage . "\n" );
338
			$status = Status::newFatal( new ApiRawMessage( $e->getMessage(), 'stashfailed' ) );
339
		}
340
341
		if ( $status->isGood() ) {
342
			$stashFile = $status->getValue();
343
			$data['filekey'] = $stashFile->getFileKey();
344
			// Backwards compatibility
345
			$data['sessionkey'] = $data['filekey'];
346
			return $data['filekey'];
347
		}
348
349
		if ( $status->getMessage()->getKey() === 'uploadstash-exception' ) {
350
			// The exceptions thrown by upload stash code and pretty silly and UploadBase returns poor
351
			// Statuses for it. Just extract the exception details and parse them ourselves.
352
			list( $exceptionType, $message ) = $status->getMessage()->getParams();
353
			$debugMessage = 'Stashing temporary file failed: ' . $exceptionType . ' ' . $message;
354
			wfDebug( __METHOD__ . ' ' . $debugMessage . "\n" );
355
			list( $msg, $code ) = $this->handleStashException( $exceptionType, $message );
356
			$status = Status::newFatal( new ApiRawMessage( $msg, $code ) );
357
		}
358
359
		// Bad status
360
		if ( $failureMode !== 'optional' ) {
361
			$this->dieStatus( $status );
362
		} else {
363
			list( $code, $msg ) = $this->getErrorFromStatus( $status );
364
			$data['stashfailed'] = $msg;
365
			return null;
366
		}
367
	}
368
369
	/**
370
	 * Throw an error that the user can recover from by providing a better
371
	 * value for $parameter
372
	 *
373
	 * @param array|string|MessageSpecifier $error Error suitable for passing to dieUsageMsg()
374
	 * @param string $parameter Parameter that needs revising
375
	 * @param array $data Optional extra data to pass to the user
376
	 * @param string $code Error code to use if the error is unknown
377
	 * @throws UsageException
378
	 */
379
	private function dieRecoverableError( $error, $parameter, $data = [], $code = 'unknownerror' ) {
380
		$this->performStash( 'optional', $data );
381
		$data['invalidparameter'] = $parameter;
382
383
		$parsed = $this->parseMsg( $error );
384
		if ( isset( $parsed['data'] ) ) {
385
			$data = array_merge( $data, $parsed['data'] );
386
		}
387
		if ( $parsed['code'] === 'unknownerror' ) {
388
			$parsed['code'] = $code;
389
		}
390
391
		$this->dieUsage( $parsed['info'], $parsed['code'], 0, $data );
392
	}
393
394
	/**
395
	 * Like dieStatus(), but always uses $overrideCode for the error code, unless the code comes from
396
	 * IApiMessage.
397
	 *
398
	 * @param Status $status
399
	 * @param string $overrideCode Error code to use if there isn't one from IApiMessage
400
	 * @param array|null $moreExtraData
401
	 * @throws UsageException
402
	 */
403
	public function dieStatusWithCode( $status, $overrideCode, $moreExtraData = null ) {
404
		$extraData = null;
405
		list( $code, $msg ) = $this->getErrorFromStatus( $status, $extraData );
406
		$errors = $status->getErrorsByType( 'error' ) ?: $status->getErrorsByType( 'warning' );
407
		if ( !( $errors[0]['message'] instanceof IApiMessage ) ) {
408
			$code = $overrideCode;
409
		}
410
		if ( $moreExtraData ) {
411
			$extraData = $extraData ?: [];
412
			$extraData += $moreExtraData;
413
		}
414
		$this->dieUsage( $msg, $code, 0, $extraData );
415
	}
416
417
	/**
418
	 * Select an upload module and set it to mUpload. Dies on failure. If the
419
	 * request was a status request and not a true upload, returns false;
420
	 * otherwise true
421
	 *
422
	 * @return bool
423
	 */
424
	protected function selectUploadModule() {
425
		$request = $this->getMain()->getRequest();
426
427
		// chunk or one and only one of the following parameters is needed
428
		if ( !$this->mParams['chunk'] ) {
429
			$this->requireOnlyOneParameter( $this->mParams,
430
				'filekey', 'file', 'url' );
431
		}
432
433
		// Status report for "upload to stash"/"upload from stash"
434
		if ( $this->mParams['filekey'] && $this->mParams['checkstatus'] ) {
435
			$progress = UploadBase::getSessionStatus( $this->getUser(), $this->mParams['filekey'] );
436
			if ( !$progress ) {
437
				$this->dieUsage( 'No result in status data', 'missingresult' );
438
			} elseif ( !$progress['status']->isGood() ) {
439
				$this->dieStatusWithCode( $progress['status'], 'stashfailed' );
440
			}
441
			if ( isset( $progress['status']->value['verification'] ) ) {
442
				$this->checkVerification( $progress['status']->value['verification'] );
443
			}
444
			if ( isset( $progress['status']->value['warnings'] ) ) {
445
				$warnings = $this->transformWarnings( $progress['status']->value['warnings'] );
446
				if ( $warnings ) {
447
					$progress['warnings'] = $warnings;
448
				}
449
			}
450
			unset( $progress['status'] ); // remove Status object
451
			$imageinfo = null;
452
			if ( isset( $progress['imageinfo'] ) ) {
453
				$imageinfo = $progress['imageinfo'];
454
				unset( $progress['imageinfo'] );
455
			}
456
457
			$this->getResult()->addValue( null, $this->getModuleName(), $progress );
458
			// Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
459
			// so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
460
			if ( $imageinfo ) {
461
				$this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
462
			}
463
464
			return false;
465
		}
466
467
		// The following modules all require the filename parameter to be set
468
		if ( is_null( $this->mParams['filename'] ) ) {
469
			$this->dieUsageMsg( [ 'missingparam', 'filename' ] );
470
		}
471
472
		if ( $this->mParams['chunk'] ) {
473
			// Chunk upload
474
			$this->mUpload = new UploadFromChunks( $this->getUser() );
475
			if ( isset( $this->mParams['filekey'] ) ) {
476
				if ( $this->mParams['offset'] === 0 ) {
477
					$this->dieUsage( 'Cannot supply a filekey when offset is 0', 'badparams' );
478
				}
479
480
				// handle new chunk
481
				$this->mUpload->continueChunks(
482
					$this->mParams['filename'],
483
					$this->mParams['filekey'],
484
					$request->getUpload( 'chunk' )
485
				);
486
			} else {
487
				if ( $this->mParams['offset'] !== 0 ) {
488
					$this->dieUsage( 'Must supply a filekey when offset is non-zero', 'badparams' );
489
				}
490
491
				// handle first chunk
492
				$this->mUpload->initialize(
493
					$this->mParams['filename'],
494
					$request->getUpload( 'chunk' )
495
				);
496
			}
497
		} elseif ( isset( $this->mParams['filekey'] ) ) {
498
			// Upload stashed in a previous request
499
			if ( !UploadFromStash::isValidKey( $this->mParams['filekey'] ) ) {
500
				$this->dieUsageMsg( 'invalid-file-key' );
501
			}
502
503
			$this->mUpload = new UploadFromStash( $this->getUser() );
504
			// This will not download the temp file in initialize() in async mode.
505
			// We still have enough information to call checkWarnings() and such.
506
			$this->mUpload->initialize(
507
				$this->mParams['filekey'], $this->mParams['filename'], !$this->mParams['async']
508
			);
509
		} elseif ( isset( $this->mParams['file'] ) ) {
510
			$this->mUpload = new UploadFromFile();
511
			$this->mUpload->initialize(
512
				$this->mParams['filename'],
513
				$request->getUpload( 'file' )
514
			);
515
		} elseif ( isset( $this->mParams['url'] ) ) {
516
			// Make sure upload by URL is enabled:
517
			if ( !UploadFromUrl::isEnabled() ) {
518
				$this->dieUsageMsg( 'copyuploaddisabled' );
519
			}
520
521
			if ( !UploadFromUrl::isAllowedHost( $this->mParams['url'] ) ) {
522
				$this->dieUsageMsg( 'copyuploadbaddomain' );
523
			}
524
525
			if ( !UploadFromUrl::isAllowedUrl( $this->mParams['url'] ) ) {
526
				$this->dieUsageMsg( 'copyuploadbadurl' );
527
			}
528
529
			$this->mUpload = new UploadFromUrl;
530
			$this->mUpload->initialize( $this->mParams['filename'],
531
				$this->mParams['url'] );
532
		}
533
534
		return true;
535
	}
536
537
	/**
538
	 * Checks that the user has permissions to perform this upload.
539
	 * Dies with usage message on inadequate permissions.
540
	 * @param User $user The user to check.
541
	 */
542
	protected function checkPermissions( $user ) {
543
		// Check whether the user has the appropriate permissions to upload anyway
544
		$permission = $this->mUpload->isAllowed( $user );
545
546
		if ( $permission !== true ) {
547
			if ( !$user->isLoggedIn() ) {
548
				$this->dieUsageMsg( [ 'mustbeloggedin', 'upload' ] );
549
			}
550
551
			$this->dieUsageMsg( 'badaccess-groups' );
552
		}
553
554
		// Check blocks
555
		if ( $user->isBlocked() ) {
556
			$this->dieBlocked( $user->getBlock() );
0 ignored issues
show
It seems like $user->getBlock() can be null; however, dieBlocked() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
557
		}
558
559
		// Global blocks
560
		if ( $user->isBlockedGlobally() ) {
561
			$this->dieBlocked( $user->getGlobalBlock() );
0 ignored issues
show
It seems like $user->getGlobalBlock() can be null; however, dieBlocked() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
562
		}
563
	}
564
565
	/**
566
	 * Performs file verification, dies on error.
567
	 */
568
	protected function verifyUpload() {
569
		$verification = $this->mUpload->verifyUpload();
570
		if ( $verification['status'] === UploadBase::OK ) {
571
			return;
572
		}
573
574
		$this->checkVerification( $verification );
575
	}
576
577
	/**
578
	 * Performs file verification, dies on error.
579
	 * @param array $verification
580
	 */
581
	protected function checkVerification( array $verification ) {
582
		// @todo Move them to ApiBase's message map
583
		switch ( $verification['status'] ) {
584
			// Recoverable errors
585
			case UploadBase::MIN_LENGTH_PARTNAME:
586
				$this->dieRecoverableError( 'filename-tooshort', 'filename' );
587
				break;
588
			case UploadBase::ILLEGAL_FILENAME:
589
				$this->dieRecoverableError( 'illegal-filename', 'filename',
590
					[ 'filename' => $verification['filtered'] ] );
591
				break;
592
			case UploadBase::FILENAME_TOO_LONG:
593
				$this->dieRecoverableError( 'filename-toolong', 'filename' );
594
				break;
595
			case UploadBase::FILETYPE_MISSING:
596
				$this->dieRecoverableError( 'filetype-missing', 'filename' );
597
				break;
598
			case UploadBase::WINDOWS_NONASCII_FILENAME:
599
				$this->dieRecoverableError( 'windows-nonascii-filename', 'filename' );
600
				break;
601
602
			// Unrecoverable errors
603
			case UploadBase::EMPTY_FILE:
604
				$this->dieUsage( 'The file you submitted was empty', 'empty-file' );
605
				break;
606
			case UploadBase::FILE_TOO_LARGE:
607
				$this->dieUsage( 'The file you submitted was too large', 'file-too-large' );
608
				break;
609
610
			case UploadBase::FILETYPE_BADTYPE:
611
				$extradata = [
612
					'filetype' => $verification['finalExt'],
613
					'allowed' => array_values( array_unique( $this->getConfig()->get( 'FileExtensions' ) ) )
614
				];
615
				ApiResult::setIndexedTagName( $extradata['allowed'], 'ext' );
616
617
				$msg = 'Filetype not permitted: ';
618
				if ( isset( $verification['blacklistedExt'] ) ) {
619
					$msg .= implode( ', ', $verification['blacklistedExt'] );
620
					$extradata['blacklisted'] = array_values( $verification['blacklistedExt'] );
621
					ApiResult::setIndexedTagName( $extradata['blacklisted'], 'ext' );
622
				} else {
623
					$msg .= $verification['finalExt'];
624
				}
625
				$this->dieUsage( $msg, 'filetype-banned', 0, $extradata );
626
				break;
627
			case UploadBase::VERIFICATION_ERROR:
628
				$parsed = $this->parseMsg( $verification['details'] );
629
				$info = "This file did not pass file verification: {$parsed['info']}";
630
				if ( $verification['details'][0] instanceof IApiMessage ) {
631
					$code = $parsed['code'];
632
				} else {
633
					// For backwards-compatibility, all of the errors from UploadBase::verifyFile() are
634
					// reported as 'verification-error', and the real error code is reported in 'details'.
635
					$code = 'verification-error';
636
				}
637
				if ( $verification['details'][0] instanceof IApiMessage ) {
638
					$msg = $verification['details'][0];
639
					$details = array_merge( [ $msg->getKey() ], $msg->getParams() );
640
				} else {
641
					$details = $verification['details'];
642
				}
643
				ApiResult::setIndexedTagName( $details, 'detail' );
644
				$data = [ 'details' => $details ];
645
				if ( isset( $parsed['data'] ) ) {
646
					$data = array_merge( $data, $parsed['data'] );
647
				}
648
649
				$this->dieUsage( $info, $code, 0, $data );
650
				break;
651
			case UploadBase::HOOK_ABORTED:
652
				if ( is_array( $verification['error'] ) ) {
653
					$params = $verification['error'];
654
				} elseif ( $verification['error'] !== '' ) {
655
					$params = [ $verification['error'] ];
656
				} else {
657
					$params = [ 'hookaborted' ];
658
				}
659
				$key = array_shift( $params );
660
				$msg = $this->msg( $key, $params )->inLanguage( 'en' )->useDatabase( false )->text();
661
				$this->dieUsage( $msg, 'hookaborted', 0, [ 'details' => $verification['error'] ] );
662
				break;
663
			default:
664
				$this->dieUsage( 'An unknown error occurred', 'unknown-error',
665
					0, [ 'details' => [ 'code' => $verification['status'] ] ] );
666
				break;
667
		}
668
	}
669
670
	/**
671
	 * Check warnings.
672
	 * Returns a suitable array for inclusion into API results if there were warnings
673
	 * Returns the empty array if there were no warnings
674
	 *
675
	 * @return array
676
	 */
677
	protected function getApiWarnings() {
678
		$warnings = $this->mUpload->checkWarnings();
679
680
		return $this->transformWarnings( $warnings );
681
	}
682
683
	protected function transformWarnings( $warnings ) {
684
		if ( $warnings ) {
685
			// Add indices
686
			ApiResult::setIndexedTagName( $warnings, 'warning' );
687
688
			if ( isset( $warnings['duplicate'] ) ) {
689
				$dupes = [];
690
				/** @var File $dupe */
691
				foreach ( $warnings['duplicate'] as $dupe ) {
692
					$dupes[] = $dupe->getName();
693
				}
694
				ApiResult::setIndexedTagName( $dupes, 'duplicate' );
695
				$warnings['duplicate'] = $dupes;
696
			}
697
698
			if ( isset( $warnings['exists'] ) ) {
699
				$warning = $warnings['exists'];
700
				unset( $warnings['exists'] );
701
				/** @var LocalFile $localFile */
702
				$localFile = isset( $warning['normalizedFile'] )
703
					? $warning['normalizedFile']
704
					: $warning['file'];
705
				$warnings[$warning['warning']] = $localFile->getName();
706
			}
707
708
			if ( isset( $warnings['no-change'] ) ) {
709
				/** @var File $file */
710
				$file = $warnings['no-change'];
711
				unset( $warnings['no-change'] );
712
713
				$warnings['nochange'] = [
714
					'timestamp' => wfTimestamp( TS_ISO_8601, $file->getTimestamp() )
715
				];
716
			}
717
718
			if ( isset( $warnings['duplicate-version'] ) ) {
719
				$dupes = [];
720
				/** @var File $dupe */
721
				foreach ( $warnings['duplicate-version'] as $dupe ) {
0 ignored issues
show
The expression $warnings['duplicate-version'] of type array|string is not guaranteed to be traversable. How about adding an additional type check?

There are different options of fixing this problem.

  1. If you want to be on the safe side, you can add an additional type-check:

    $collection = json_decode($data, true);
    if ( ! is_array($collection)) {
        throw new \RuntimeException('$collection must be an array.');
    }
    
    foreach ($collection as $item) { /** ... */ }
    
  2. If you are sure that the expression is traversable, you might want to add a doc comment cast to improve IDE auto-completion and static analysis:

    /** @var array $collection */
    $collection = json_decode($data, true);
    
    foreach ($collection as $item) { /** .. */ }
    
  3. Mark the issue as a false-positive: Just hover the remove button, in the top-right corner of this issue for more options.

Loading history...
722
					$dupes[] = [
723
						'timestamp' => wfTimestamp( TS_ISO_8601, $dupe->getTimestamp() )
724
					];
725
				}
726
				unset( $warnings['duplicate-version'] );
727
728
				ApiResult::setIndexedTagName( $dupes, 'ver' );
729
				$warnings['duplicateversions'] = $dupes;
730
			}
731
		}
732
733
		return $warnings;
734
	}
735
736
	/**
737
	 * Handles a stash exception, giving a useful error to the user.
738
	 * @param string $exceptionType Class name of the exception we encountered.
739
	 * @param string $message Message of the exception we encountered.
740
	 * @return array Array of message and code, suitable for passing to dieUsage()
741
	 */
742
	protected function handleStashException( $exceptionType, $message ) {
743
		switch ( $exceptionType ) {
744
			case 'UploadStashFileNotFoundException':
745
				return [
746
					'Could not find the file in the stash: ' . $message,
747
					'stashedfilenotfound'
748
				];
749
			case 'UploadStashBadPathException':
750
				return [
751
					'File key of improper format or otherwise invalid: ' . $message,
752
					'stashpathinvalid'
753
				];
754
			case 'UploadStashFileException':
755
				return [
756
					'Could not store upload in the stash: ' . $message,
757
					'stashfilestorage'
758
				];
759
			case 'UploadStashZeroLengthFileException':
760
				return [
761
					'File is of zero length, and could not be stored in the stash: ' .
762
						$message,
763
					'stashzerolength'
764
				];
765
			case 'UploadStashNotLoggedInException':
766
				return [ 'Not logged in: ' . $message, 'stashnotloggedin' ];
767
			case 'UploadStashWrongOwnerException':
768
				return [ 'Wrong owner: ' . $message, 'stashwrongowner' ];
769
			case 'UploadStashNoSuchKeyException':
770
				return [ 'No such filekey: ' . $message, 'stashnosuchfilekey' ];
771
			default:
772
				return [ $exceptionType . ': ' . $message, 'stasherror' ];
773
		}
774
	}
775
776
	/**
777
	 * Perform the actual upload. Returns a suitable result array on success;
778
	 * dies on failure.
779
	 *
780
	 * @param array $warnings Array of Api upload warnings
781
	 * @return array
782
	 */
783
	protected function performUpload( $warnings ) {
784
		// Use comment as initial page text by default
785
		if ( is_null( $this->mParams['text'] ) ) {
786
			$this->mParams['text'] = $this->mParams['comment'];
787
		}
788
789
		/** @var $file LocalFile */
790
		$file = $this->mUpload->getLocalFile();
791
792
		// For preferences mode, we want to watch if 'watchdefault' is set,
793
		// or if the *file* doesn't exist, and either 'watchuploads' or
794
		// 'watchcreations' is set. But getWatchlistValue()'s automatic
795
		// handling checks if the *title* exists or not, so we need to check
796
		// all three preferences manually.
797
		$watch = $this->getWatchlistValue(
798
			$this->mParams['watchlist'], $file->getTitle(), 'watchdefault'
799
		);
800
801
		if ( !$watch && $this->mParams['watchlist'] == 'preferences' && !$file->exists() ) {
802
			$watch = (
803
				$this->getWatchlistValue( 'preferences', $file->getTitle(), 'watchuploads' ) ||
804
				$this->getWatchlistValue( 'preferences', $file->getTitle(), 'watchcreations' )
805
			);
806
		}
807
808
		// Deprecated parameters
809
		if ( $this->mParams['watch'] ) {
810
			$watch = true;
811
		}
812
813
		if ( $this->mParams['tags'] ) {
814
			$status = ChangeTags::canAddTagsAccompanyingChange( $this->mParams['tags'], $this->getUser() );
815
			if ( !$status->isOK() ) {
816
				$this->dieStatus( $status );
817
			}
818
		}
819
820
		// No errors, no warnings: do the upload
821
		if ( $this->mParams['async'] ) {
822
			$progress = UploadBase::getSessionStatus( $this->getUser(), $this->mParams['filekey'] );
823
			if ( $progress && $progress['result'] === 'Poll' ) {
824
				$this->dieUsage( 'Upload from stash already in progress.', 'publishfailed' );
825
			}
826
			UploadBase::setSessionStatus(
827
				$this->getUser(),
828
				$this->mParams['filekey'],
829
				[ 'result' => 'Poll', 'stage' => 'queued', 'status' => Status::newGood() ]
830
			);
831
			JobQueueGroup::singleton()->push( new PublishStashedFileJob(
832
				Title::makeTitle( NS_FILE, $this->mParams['filename'] ),
833
				[
834
					'filename' => $this->mParams['filename'],
835
					'filekey' => $this->mParams['filekey'],
836
					'comment' => $this->mParams['comment'],
837
					'tags' => $this->mParams['tags'],
838
					'text' => $this->mParams['text'],
839
					'watch' => $watch,
840
					'session' => $this->getContext()->exportSession()
841
				]
842
			) );
843
			$result['result'] = 'Poll';
844
			$result['stage'] = 'queued';
845
		} else {
846
			/** @var $status Status */
847
			$status = $this->mUpload->performUpload( $this->mParams['comment'],
848
				$this->mParams['text'], $watch, $this->getUser(), $this->mParams['tags'] );
849
850
			if ( !$status->isGood() ) {
851
				// Is there really no better way to do this?
852
				$errors = $status->getErrorsByType( 'error' );
853
				$msg = array_merge( [ $errors[0]['message'] ], $errors[0]['params'] );
854
				$data = $status->getErrorsArray();
855
				ApiResult::setIndexedTagName( $data, 'error' );
856
				// For backwards-compatibility, we use the 'internal-error' fallback key and merge $data
857
				// into the root of the response (rather than something sane like [ 'details' => $data ]).
858
				$this->dieRecoverableError( $msg, null, $data, 'internal-error' );
859
			}
860
			$result['result'] = 'Success';
861
		}
862
863
		$result['filename'] = $file->getName();
864
		if ( $warnings && count( $warnings ) > 0 ) {
865
			$result['warnings'] = $warnings;
866
		}
867
868
		return $result;
869
	}
870
871
	public function mustBePosted() {
872
		return true;
873
	}
874
875
	public function isWriteMode() {
876
		return true;
877
	}
878
879
	public function getAllowedParams() {
880
		$params = [
881
			'filename' => [
882
				ApiBase::PARAM_TYPE => 'string',
883
			],
884
			'comment' => [
885
				ApiBase::PARAM_DFLT => ''
886
			],
887
			'tags' => [
888
				ApiBase::PARAM_TYPE => 'tags',
889
				ApiBase::PARAM_ISMULTI => true,
890
			],
891
			'text' => [
892
				ApiBase::PARAM_TYPE => 'text',
893
			],
894
			'watch' => [
895
				ApiBase::PARAM_DFLT => false,
896
				ApiBase::PARAM_DEPRECATED => true,
897
			],
898
			'watchlist' => [
899
				ApiBase::PARAM_DFLT => 'preferences',
900
				ApiBase::PARAM_TYPE => [
901
					'watch',
902
					'preferences',
903
					'nochange'
904
				],
905
			],
906
			'ignorewarnings' => false,
907
			'file' => [
908
				ApiBase::PARAM_TYPE => 'upload',
909
			],
910
			'url' => null,
911
			'filekey' => null,
912
			'sessionkey' => [
913
				ApiBase::PARAM_DEPRECATED => true,
914
			],
915
			'stash' => false,
916
917
			'filesize' => [
918
				ApiBase::PARAM_TYPE => 'integer',
919
				ApiBase::PARAM_MIN => 0,
920
				ApiBase::PARAM_MAX => UploadBase::getMaxUploadSize(),
921
			],
922
			'offset' => [
923
				ApiBase::PARAM_TYPE => 'integer',
924
				ApiBase::PARAM_MIN => 0,
925
			],
926
			'chunk' => [
927
				ApiBase::PARAM_TYPE => 'upload',
928
			],
929
930
			'async' => false,
931
			'checkstatus' => false,
932
		];
933
934
		return $params;
935
	}
936
937
	public function needsToken() {
938
		return 'csrf';
939
	}
940
941
	protected function getExamplesMessages() {
942
		return [
943
			'action=upload&filename=Wiki.png' .
944
				'&url=http%3A//upload.wikimedia.org/wikipedia/en/b/bc/Wiki.png&token=123ABC'
945
				=> 'apihelp-upload-example-url',
946
			'action=upload&filename=Wiki.png&filekey=filekey&ignorewarnings=1&token=123ABC'
947
				=> 'apihelp-upload-example-filekey',
948
		];
949
	}
950
951
	public function getHelpUrls() {
952
		return 'https://www.mediawiki.org/wiki/API:Upload';
953
	}
954
}
955