Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
47.36% |
323 / 682 |
|
38.46% |
10 / 26 |
CRAP | |
0.00% |
0 / 1 |
ApiUpload | |
47.36% |
323 / 682 |
|
38.46% |
10 / 26 |
3659.24 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
execute | |
64.44% |
29 / 45 |
|
0.00% |
0 / 1 |
27.51 | |||
getDummyInstance | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
getUploadImageInfo | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
2 | |||
getContextResult | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
5 | |||
getStashResult | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
getWarningsResult | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
getMinUploadChunkSize | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
2 | |||
getChunkResult | |
50.00% |
69 / 138 |
|
0.00% |
0 / 1 |
43.12 | |||
performStash | |
20.93% |
9 / 43 |
|
0.00% |
0 / 1 |
31.22 | |||
dieRecoverableError | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
dieStatusWithCode | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
selectUploadModule | |
44.16% |
34 / 77 |
|
0.00% |
0 / 1 |
143.73 | |||
checkPermissions | |
42.86% |
3 / 7 |
|
0.00% |
0 / 1 |
6.99 | |||
verifyUpload | |
81.48% |
22 / 27 |
|
0.00% |
0 / 1 |
10.64 | |||
checkVerification | |
4.35% |
2 / 46 |
|
0.00% |
0 / 1 |
211.91 | |||
getApiWarnings | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
transformWarnings | |
51.72% |
15 / 29 |
|
0.00% |
0 / 1 |
18.11 | |||
handleStashException | |
0.00% |
0 / 29 |
|
0.00% |
0 / 1 |
90 | |||
performUpload | |
28.26% |
26 / 92 |
|
0.00% |
0 / 1 |
110.52 | |||
mustBePosted | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
isWriteMode | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getAllowedParams | |
100.00% |
51 / 51 |
|
100.00% |
1 / 1 |
1 | |||
needsToken | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getExamplesMessages | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
getHelpUrls | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | /** |
3 | * Copyright © 2008 - 2010 Bryan Tong Minh <Bryan.TongMinh@Gmail.com> |
4 | * |
5 | * This program is free software; you can redistribute it and/or modify |
6 | * it under the terms of the GNU General Public License as published by |
7 | * the Free Software Foundation; either version 2 of the License, or |
8 | * (at your option) any later version. |
9 | * |
10 | * This program is distributed in the hope that it will be useful, |
11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
13 | * GNU General Public License for more details. |
14 | * |
15 | * You should have received a copy of the GNU General Public License along |
16 | * with this program; if not, write to the Free Software Foundation, Inc., |
17 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
18 | * http://www.gnu.org/copyleft/gpl.html |
19 | * |
20 | * @file |
21 | */ |
22 | |
23 | /** |
24 | * @todo: create a UploadCommandFactory and UploadComand classes to share logic with Special:Upload |
25 | * @todo: split the different cases of upload in subclasses or submethods. |
26 | */ |
27 | |
28 | use MediaWiki\Config\Config; |
29 | use MediaWiki\Logger\LoggerFactory; |
30 | use MediaWiki\MainConfigNames; |
31 | use MediaWiki\MediaWikiServices; |
32 | use MediaWiki\Status\Status; |
33 | use MediaWiki\User\Options\UserOptionsLookup; |
34 | use MediaWiki\User\User; |
35 | use MediaWiki\Watchlist\WatchlistManager; |
36 | use Psr\Log\LoggerInterface; |
37 | use Wikimedia\ParamValidator\ParamValidator; |
38 | use Wikimedia\ParamValidator\TypeDef\IntegerDef; |
39 | |
40 | /** |
41 | * @ingroup API |
42 | */ |
43 | class ApiUpload extends ApiBase { |
44 | |
45 | use ApiWatchlistTrait; |
46 | |
47 | /** @var UploadBase|UploadFromChunks */ |
48 | protected $mUpload = null; |
49 | |
50 | protected $mParams; |
51 | |
52 | private JobQueueGroup $jobQueueGroup; |
53 | |
54 | private LoggerInterface $log; |
55 | |
56 | /** |
57 | * @param ApiMain $mainModule |
58 | * @param string $moduleName |
59 | * @param JobQueueGroup $jobQueueGroup |
60 | * @param WatchlistManager $watchlistManager |
61 | * @param UserOptionsLookup $userOptionsLookup |
62 | */ |
63 | public function __construct( |
64 | ApiMain $mainModule, |
65 | $moduleName, |
66 | JobQueueGroup $jobQueueGroup, |
67 | WatchlistManager $watchlistManager, |
68 | UserOptionsLookup $userOptionsLookup |
69 | ) { |
70 | parent::__construct( $mainModule, $moduleName ); |
71 | $this->jobQueueGroup = $jobQueueGroup; |
72 | |
73 | // Variables needed in ApiWatchlistTrait trait |
74 | $this->watchlistExpiryEnabled = $this->getConfig()->get( MainConfigNames::WatchlistExpiry ); |
75 | $this->watchlistMaxDuration = |
76 | $this->getConfig()->get( MainConfigNames::WatchlistExpiryMaxDuration ); |
77 | $this->watchlistManager = $watchlistManager; |
78 | $this->userOptionsLookup = $userOptionsLookup; |
79 | $this->log = LoggerFactory::getInstance( 'upload' ); |
80 | } |
81 | |
82 | public function execute() { |
83 | // Check whether upload is enabled |
84 | if ( !UploadBase::isEnabled() ) { |
85 | $this->dieWithError( 'uploaddisabled' ); |
86 | } |
87 | |
88 | $user = $this->getUser(); |
89 | |
90 | // Parameter handling |
91 | $this->mParams = $this->extractRequestParams(); |
92 | $request = $this->getMain()->getRequest(); |
93 | // Check if async mode is actually supported (jobs done in cli mode) |
94 | $this->mParams['async'] = ( $this->mParams['async'] && |
95 | $this->getConfig()->get( MainConfigNames::EnableAsyncUploads ) ); |
96 | // Add the uploaded file to the params array |
97 | $this->mParams['file'] = $request->getFileName( 'file' ); |
98 | $this->mParams['chunk'] = $request->getFileName( 'chunk' ); |
99 | |
100 | // Copy the session key to the file key, for backward compatibility. |
101 | if ( !$this->mParams['filekey'] && $this->mParams['sessionkey'] ) { |
102 | $this->mParams['filekey'] = $this->mParams['sessionkey']; |
103 | } |
104 | |
105 | if ( !$this->mParams['checkstatus'] ) { |
106 | $this->useTransactionalTimeLimit(); |
107 | } |
108 | |
109 | // Select an upload module |
110 | try { |
111 | if ( !$this->selectUploadModule() ) { |
112 | return; // not a true upload, but a status request or similar |
113 | } elseif ( !isset( $this->mUpload ) ) { |
114 | $this->dieDebug( __METHOD__, 'No upload module set' ); |
115 | } |
116 | } catch ( UploadStashException $e ) { // XXX: don't spam exception log |
117 | $this->dieStatus( $this->handleStashException( $e ) ); |
118 | } |
119 | |
120 | // First check permission to upload |
121 | $this->checkPermissions( $user ); |
122 | |
123 | // Fetch the file (usually a no-op) |
124 | // Skip for async upload from URL, where we just want to run checks. |
125 | /** @var Status $status */ |
126 | if ( $this->mParams['async'] && $this->mParams['url'] ) { |
127 | $status = $this->mUpload->canFetchFile(); |
128 | } else { |
129 | $status = $this->mUpload->fetchFile(); |
130 | } |
131 | |
132 | if ( !$status->isGood() ) { |
133 | $this->log->info( "Unable to fetch file {filename} for {user} because {status}", |
134 | [ |
135 | 'user' => $this->getUser()->getName(), |
136 | 'status' => (string)$status, |
137 | 'filename' => $this->mParams['filename'] ?? '-', |
138 | ] |
139 | ); |
140 | $this->dieStatus( $status ); |
141 | } |
142 | |
143 | // Check the uploaded file |
144 | $this->verifyUpload(); |
145 | |
146 | // Check if the user has the rights to modify or overwrite the requested title |
147 | // (This check is irrelevant if stashing is already requested, since the errors |
148 | // can always be fixed by changing the title) |
149 | if ( !$this->mParams['stash'] ) { |
150 | $permErrors = $this->mUpload->verifyTitlePermissions( $user ); |
151 | if ( $permErrors !== true ) { |
152 | $this->dieRecoverableError( $permErrors, 'filename' ); |
153 | } |
154 | } |
155 | |
156 | // Get the result based on the current upload context: |
157 | try { |
158 | $result = $this->getContextResult(); |
159 | } catch ( UploadStashException $e ) { // XXX: don't spam exception log |
160 | $this->dieStatus( $this->handleStashException( $e ) ); |
161 | } |
162 | $this->getResult()->addValue( null, $this->getModuleName(), $result ); |
163 | |
164 | // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large, |
165 | // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993). |
166 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable False positive |
167 | if ( $result['result'] === 'Success' ) { |
168 | $imageinfo = $this->getUploadImageInfo( $this->mUpload ); |
169 | $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo ); |
170 | } |
171 | |
172 | // Cleanup any temporary mess |
173 | $this->mUpload->cleanupTempFile(); |
174 | } |
175 | |
176 | public static function getDummyInstance(): self { |
177 | $services = MediaWikiServices::getInstance(); |
178 | $apiMain = new ApiMain(); // dummy object (XXX) |
179 | $apiUpload = new ApiUpload( |
180 | $apiMain, |
181 | 'upload', |
182 | $services->getJobQueueGroup(), |
183 | $services->getWatchlistManager(), |
184 | $services->getUserOptionsLookup() |
185 | ); |
186 | |
187 | return $apiUpload; |
188 | } |
189 | |
190 | /** |
191 | * Gets image info about the file just uploaded. |
192 | * |
193 | * Also has the effect of setting metadata to be an 'indexed tag name' in |
194 | * returned API result if 'metadata' was requested. Oddly, we have to pass |
195 | * the "result" object down just so it can do that with the appropriate |
196 | * format, presumably. |
197 | * |
198 | * @internal For use in upload jobs and a deprecated method on UploadBase. |
199 | * @todo Extract the logic actually needed by the jobs, and separate it |
200 | * from the structure used in API responses. |
201 | * |
202 | * @return array Image info |
203 | */ |
204 | public function getUploadImageInfo( UploadBase $upload ): array { |
205 | $result = $this->getResult(); |
206 | $stashFile = $upload->getStashFile(); |
207 | |
208 | // Calling a different API module depending on whether the file was stashed is less than optimal. |
209 | // In fact, calling API modules here at all is less than optimal. Maybe it should be refactored. |
210 | if ( $stashFile ) { |
211 | $imParam = ApiQueryStashImageInfo::getPropertyNames(); |
212 | $info = ApiQueryStashImageInfo::getInfo( |
213 | $stashFile, |
214 | array_fill_keys( $imParam, true ), |
215 | $result |
216 | ); |
217 | } else { |
218 | $localFile = $upload->getLocalFile(); |
219 | $imParam = ApiQueryImageInfo::getPropertyNames(); |
220 | $info = ApiQueryImageInfo::getInfo( |
221 | $localFile, |
222 | array_fill_keys( $imParam, true ), |
223 | $result |
224 | ); |
225 | } |
226 | |
227 | return $info; |
228 | } |
229 | |
230 | /** |
231 | * Get an upload result based on upload context |
232 | * @return array |
233 | */ |
234 | private function getContextResult() { |
235 | $warnings = $this->getApiWarnings(); |
236 | if ( $warnings && !$this->mParams['ignorewarnings'] ) { |
237 | // Get warnings formatted in result array format |
238 | return $this->getWarningsResult( $warnings ); |
239 | } elseif ( $this->mParams['chunk'] ) { |
240 | // Add chunk, and get result |
241 | return $this->getChunkResult( $warnings ); |
242 | } elseif ( $this->mParams['stash'] ) { |
243 | // Stash the file and get stash result |
244 | return $this->getStashResult( $warnings ); |
245 | } |
246 | |
247 | // This is the most common case -- a normal upload with no warnings |
248 | // performUpload will return a formatted properly for the API with status |
249 | return $this->performUpload( $warnings ); |
250 | } |
251 | |
252 | /** |
253 | * Get Stash Result, throws an exception if the file could not be stashed. |
254 | * @param array $warnings Array of Api upload warnings |
255 | * @return array |
256 | */ |
257 | private function getStashResult( $warnings ) { |
258 | $result = []; |
259 | $result['result'] = 'Success'; |
260 | if ( $warnings && count( $warnings ) > 0 ) { |
261 | $result['warnings'] = $warnings; |
262 | } |
263 | // Some uploads can request they be stashed, so as not to publish them immediately. |
264 | // In this case, a failure to stash ought to be fatal |
265 | $this->performStash( 'critical', $result ); |
266 | |
267 | return $result; |
268 | } |
269 | |
270 | /** |
271 | * Get Warnings Result |
272 | * @param array $warnings Array of Api upload warnings |
273 | * @return array |
274 | */ |
275 | private function getWarningsResult( $warnings ) { |
276 | $result = []; |
277 | $result['result'] = 'Warning'; |
278 | $result['warnings'] = $warnings; |
279 | // in case the warnings can be fixed with some further user action, let's stash this upload |
280 | // and return a key they can use to restart it |
281 | $this->performStash( 'optional', $result ); |
282 | |
283 | return $result; |
284 | } |
285 | |
286 | /** |
287 | * @since 1.35 |
288 | * @see $wgMinUploadChunkSize |
289 | * @param Config $config Site configuration for MinUploadChunkSize |
290 | * @return int |
291 | */ |
292 | public static function getMinUploadChunkSize( Config $config ) { |
293 | $configured = $config->get( MainConfigNames::MinUploadChunkSize ); |
294 | |
295 | // Leave some room for other POST parameters |
296 | $postMax = ( |
297 | wfShorthandToInteger( |
298 | ini_get( 'post_max_size' ), |
299 | PHP_INT_MAX |
300 | ) ?: PHP_INT_MAX |
301 | ) - 1024; |
302 | |
303 | // Ensure the minimum chunk size is less than PHP upload limits |
304 | // or the maximum upload size. |
305 | return min( |
306 | $configured, |
307 | UploadBase::getMaxUploadSize( 'file' ), |
308 | UploadBase::getMaxPhpUploadSize(), |
309 | $postMax |
310 | ); |
311 | } |
312 | |
313 | /** |
314 | * Get the result of a chunk upload. |
315 | * @param array $warnings Array of Api upload warnings |
316 | * @return array |
317 | */ |
318 | private function getChunkResult( $warnings ) { |
319 | $result = []; |
320 | |
321 | if ( $warnings && count( $warnings ) > 0 ) { |
322 | $result['warnings'] = $warnings; |
323 | } |
324 | |
325 | $request = $this->getMain()->getRequest(); |
326 | $chunkPath = $request->getFileTempname( 'chunk' ); |
327 | $chunkSize = $request->getUpload( 'chunk' )->getSize(); |
328 | $totalSoFar = $this->mParams['offset'] + $chunkSize; |
329 | $minChunkSize = self::getMinUploadChunkSize( $this->getConfig() ); |
330 | |
331 | // Double check sizing |
332 | if ( $totalSoFar > $this->mParams['filesize'] ) { |
333 | $this->dieWithError( 'apierror-invalid-chunk' ); |
334 | } |
335 | |
336 | // Enforce minimum chunk size |
337 | if ( $totalSoFar != $this->mParams['filesize'] && $chunkSize < $minChunkSize ) { |
338 | $this->dieWithError( [ 'apierror-chunk-too-small', Message::numParam( $minChunkSize ) ] ); |
339 | } |
340 | |
341 | if ( $this->mParams['offset'] == 0 ) { |
342 | $this->log->debug( "Started first chunk of chunked upload of {filename} for {user}", |
343 | [ |
344 | 'user' => $this->getUser()->getName(), |
345 | 'filename' => $this->mParams['filename'] ?? '-', |
346 | 'filesize' => $this->mParams['filesize'], |
347 | 'chunkSize' => $chunkSize |
348 | ] |
349 | ); |
350 | $filekey = $this->performStash( 'critical' ); |
351 | } else { |
352 | $filekey = $this->mParams['filekey']; |
353 | |
354 | // Don't allow further uploads to an already-completed session |
355 | $progress = UploadBase::getSessionStatus( $this->getUser(), $filekey ); |
356 | if ( !$progress ) { |
357 | // Probably can't get here, but check anyway just in case |
358 | $this->log->info( "Stash failed due to no session for {user}", |
359 | [ |
360 | 'user' => $this->getUser()->getName(), |
361 | 'filename' => $this->mParams['filename'] ?? '-', |
362 | 'filekey' => $this->mParams['filekey'] ?? '-', |
363 | 'filesize' => $this->mParams['filesize'], |
364 | 'chunkSize' => $chunkSize |
365 | ] |
366 | ); |
367 | $this->dieWithError( 'apierror-stashfailed-nosession', 'stashfailed' ); |
368 | } elseif ( $progress['result'] !== 'Continue' || $progress['stage'] !== 'uploading' ) { |
369 | $this->dieWithError( 'apierror-stashfailed-complete', 'stashfailed' ); |
370 | } |
371 | |
372 | $status = $this->mUpload->addChunk( |
373 | $chunkPath, $chunkSize, $this->mParams['offset'] ); |
374 | if ( !$status->isGood() ) { |
375 | $extradata = [ |
376 | 'offset' => $this->mUpload->getOffset(), |
377 | ]; |
378 | $this->log->info( "Chunked upload stash failure {status} for {user}", |
379 | [ |
380 | 'status' => (string)$status, |
381 | 'user' => $this->getUser()->getName(), |
382 | 'filename' => $this->mParams['filename'] ?? '-', |
383 | 'filekey' => $this->mParams['filekey'] ?? '-', |
384 | 'filesize' => $this->mParams['filesize'], |
385 | 'chunkSize' => $chunkSize, |
386 | 'offset' => $this->mUpload->getOffset() |
387 | ] |
388 | ); |
389 | $this->dieStatusWithCode( $status, 'stashfailed', $extradata ); |
390 | } else { |
391 | $this->log->debug( "Got chunk for {filename} with offset {offset} for {user}", |
392 | [ |
393 | 'user' => $this->getUser()->getName(), |
394 | 'filename' => $this->mParams['filename'] ?? '-', |
395 | 'filekey' => $this->mParams['filekey'] ?? '-', |
396 | 'filesize' => $this->mParams['filesize'], |
397 | 'chunkSize' => $chunkSize, |
398 | 'offset' => $this->mUpload->getOffset() |
399 | ] |
400 | ); |
401 | } |
402 | } |
403 | |
404 | // Check we added the last chunk: |
405 | if ( $totalSoFar == $this->mParams['filesize'] ) { |
406 | if ( $this->mParams['async'] ) { |
407 | UploadBase::setSessionStatus( |
408 | $this->getUser(), |
409 | $filekey, |
410 | [ 'result' => 'Poll', |
411 | 'stage' => 'queued', 'status' => Status::newGood() ] |
412 | ); |
413 | // It is important that this be lazyPush, as we do not want to insert |
414 | // into job queue until after the current transaction has completed since |
415 | // this depends on values in uploadstash table that were updated during |
416 | // the current transaction. (T350917) |
417 | $this->jobQueueGroup->lazyPush( new AssembleUploadChunksJob( [ |
418 | 'filename' => $this->mParams['filename'], |
419 | 'filekey' => $filekey, |
420 | 'filesize' => $this->mParams['filesize'], |
421 | 'session' => $this->getContext()->exportSession() |
422 | ] ) ); |
423 | $this->log->info( "Received final chunk of {filename} for {user}, queuing assemble job", |
424 | [ |
425 | 'user' => $this->getUser()->getName(), |
426 | 'filename' => $this->mParams['filename'] ?? '-', |
427 | 'filekey' => $this->mParams['filekey'] ?? '-', |
428 | 'filesize' => $this->mParams['filesize'], |
429 | 'chunkSize' => $chunkSize, |
430 | ] |
431 | ); |
432 | $result['result'] = 'Poll'; |
433 | $result['stage'] = 'queued'; |
434 | } else { |
435 | $this->log->info( "Received final chunk of {filename} for {user}, assembling immediately", |
436 | [ |
437 | 'user' => $this->getUser()->getName(), |
438 | 'filename' => $this->mParams['filename'] ?? '-', |
439 | 'filekey' => $this->mParams['filekey'] ?? '-', |
440 | 'filesize' => $this->mParams['filesize'], |
441 | 'chunkSize' => $chunkSize, |
442 | ] |
443 | ); |
444 | |
445 | $status = $this->mUpload->concatenateChunks(); |
446 | if ( !$status->isGood() ) { |
447 | UploadBase::setSessionStatus( |
448 | $this->getUser(), |
449 | $filekey, |
450 | [ 'result' => 'Failure', 'stage' => 'assembling', 'status' => $status ] |
451 | ); |
452 | $this->log->info( "Non jobqueue assembly of {filename} failed because {status}", |
453 | [ |
454 | 'user' => $this->getUser()->getName(), |
455 | 'filename' => $this->mParams['filename'] ?? '-', |
456 | 'filekey' => $this->mParams['filekey'] ?? '-', |
457 | 'filesize' => $this->mParams['filesize'], |
458 | 'chunkSize' => $chunkSize, |
459 | 'status' => (string)$status |
460 | ] |
461 | ); |
462 | $this->dieStatusWithCode( $status, 'stashfailed' ); |
463 | } |
464 | |
465 | // We can only get warnings like 'duplicate' after concatenating the chunks |
466 | $warnings = $this->getApiWarnings(); |
467 | if ( $warnings ) { |
468 | $result['warnings'] = $warnings; |
469 | } |
470 | |
471 | // The fully concatenated file has a new filekey. So remove |
472 | // the old filekey and fetch the new one. |
473 | UploadBase::setSessionStatus( $this->getUser(), $filekey, false ); |
474 | $this->mUpload->stash->removeFile( $filekey ); |
475 | $filekey = $this->mUpload->getStashFile()->getFileKey(); |
476 | |
477 | $result['result'] = 'Success'; |
478 | } |
479 | } else { |
480 | UploadBase::setSessionStatus( |
481 | $this->getUser(), |
482 | $filekey, |
483 | [ |
484 | 'result' => 'Continue', |
485 | 'stage' => 'uploading', |
486 | 'offset' => $totalSoFar, |
487 | 'status' => Status::newGood(), |
488 | ] |
489 | ); |
490 | $result['result'] = 'Continue'; |
491 | $result['offset'] = $totalSoFar; |
492 | } |
493 | |
494 | $result['filekey'] = $filekey; |
495 | |
496 | return $result; |
497 | } |
498 | |
499 | /** |
500 | * Stash the file and add the file key, or error information if it fails, to the data. |
501 | * |
502 | * @param string $failureMode What to do on failure to stash: |
503 | * - When 'critical', use dieStatus() to produce an error response and throw an exception. |
504 | * Use this when stashing the file was the primary purpose of the API request. |
505 | * - When 'optional', only add a 'stashfailed' key to the data and return null. |
506 | * Use this when some error happened for a non-stash upload and we're stashing the file |
507 | * only to save the client the trouble of re-uploading it. |
508 | * @param array|null &$data API result to which to add the information |
509 | * @return string|null File key |
510 | */ |
511 | private function performStash( $failureMode, &$data = null ) { |
512 | $isPartial = (bool)$this->mParams['chunk']; |
513 | try { |
514 | $status = $this->mUpload->tryStashFile( $this->getUser(), $isPartial ); |
515 | |
516 | if ( $status->isGood() && !$status->getValue() ) { |
517 | // Not actually a 'good' status... |
518 | $status->fatal( new ApiMessage( 'apierror-stashinvalidfile', 'stashfailed' ) ); |
519 | } |
520 | } catch ( Exception $e ) { |
521 | $debugMessage = 'Stashing temporary file failed: ' . get_class( $e ) . ' ' . $e->getMessage(); |
522 | $this->log->info( $debugMessage, |
523 | [ |
524 | 'user' => $this->getUser()->getName(), |
525 | 'filename' => $this->mParams['filename'] ?? '-', |
526 | 'filekey' => $this->mParams['filekey'] ?? '-' |
527 | ] |
528 | ); |
529 | |
530 | $status = Status::newFatal( $this->getErrorFormatter()->getMessageFromException( |
531 | $e, [ 'wrap' => new ApiMessage( 'apierror-stashexception', 'stashfailed' ) ] |
532 | ) ); |
533 | } |
534 | |
535 | if ( $status->isGood() ) { |
536 | $stashFile = $status->getValue(); |
537 | $data['filekey'] = $stashFile->getFileKey(); |
538 | // Backwards compatibility |
539 | $data['sessionkey'] = $data['filekey']; |
540 | return $data['filekey']; |
541 | } |
542 | |
543 | if ( $status->getMessage()->getKey() === 'uploadstash-exception' ) { |
544 | // The exceptions thrown by upload stash code and pretty silly and UploadBase returns poor |
545 | // Statuses for it. Just extract the exception details and parse them ourselves. |
546 | [ $exceptionType, $message ] = $status->getMessage()->getParams(); |
547 | $debugMessage = 'Stashing temporary file failed: ' . $exceptionType . ' ' . $message; |
548 | $this->log->info( $debugMessage, |
549 | [ |
550 | 'user' => $this->getUser()->getName(), |
551 | 'filename' => $this->mParams['filename'] ?? '-', |
552 | 'filekey' => $this->mParams['filekey'] ?? '-' |
553 | ] |
554 | ); |
555 | } |
556 | |
557 | $this->log->info( "Stash upload failure {status}", |
558 | [ |
559 | 'status' => (string)$status, |
560 | 'user' => $this->getUser()->getName(), |
561 | 'filename' => $this->mParams['filename'] ?? '-', |
562 | 'filekey' => $this->mParams['filekey'] ?? '-' |
563 | ] |
564 | ); |
565 | // Bad status |
566 | if ( $failureMode !== 'optional' ) { |
567 | $this->dieStatus( $status ); |
568 | } else { |
569 | $data['stasherrors'] = $this->getErrorFormatter()->arrayFromStatus( $status ); |
570 | return null; |
571 | } |
572 | } |
573 | |
574 | /** |
575 | * Throw an error that the user can recover from by providing a better |
576 | * value for $parameter |
577 | * |
578 | * @param array $errors Array of Message objects, message keys, key+param |
579 | * arrays, or StatusValue::getErrors()-style arrays |
580 | * @param string|null $parameter Parameter that needs revising |
581 | * @throws ApiUsageException |
582 | * @return never |
583 | */ |
584 | private function dieRecoverableError( $errors, $parameter = null ) { |
585 | $this->performStash( 'optional', $data ); |
586 | |
587 | if ( $parameter ) { |
588 | $data['invalidparameter'] = $parameter; |
589 | } |
590 | |
591 | $sv = StatusValue::newGood(); |
592 | foreach ( $errors as $error ) { |
593 | $msg = ApiMessage::create( $error ); |
594 | $msg->setApiData( $msg->getApiData() + $data ); |
595 | $sv->fatal( $msg ); |
596 | } |
597 | $this->dieStatus( $sv ); |
598 | } |
599 | |
600 | /** |
601 | * Like dieStatus(), but always uses $overrideCode for the error code, unless the code comes from |
602 | * IApiMessage. |
603 | * |
604 | * @param Status $status |
605 | * @param string $overrideCode Error code to use if there isn't one from IApiMessage |
606 | * @param array|null $moreExtraData |
607 | * @throws ApiUsageException |
608 | * @return never |
609 | */ |
610 | public function dieStatusWithCode( $status, $overrideCode, $moreExtraData = null ) { |
611 | $sv = StatusValue::newGood(); |
612 | foreach ( $status->getErrors() as $error ) { |
613 | $msg = ApiMessage::create( $error, $overrideCode ); |
614 | if ( $moreExtraData ) { |
615 | $msg->setApiData( $msg->getApiData() + $moreExtraData ); |
616 | } |
617 | $sv->fatal( $msg ); |
618 | } |
619 | $this->dieStatus( $sv ); |
620 | } |
621 | |
622 | /** |
623 | * Select an upload module and set it to mUpload. Dies on failure. If the |
624 | * request was a status request and not a true upload, returns false; |
625 | * otherwise true |
626 | * |
627 | * @return bool |
628 | */ |
629 | protected function selectUploadModule() { |
630 | $request = $this->getMain()->getRequest(); |
631 | |
632 | // chunk or one and only one of the following parameters is needed |
633 | if ( !$this->mParams['chunk'] ) { |
634 | $this->requireOnlyOneParameter( $this->mParams, |
635 | 'filekey', 'file', 'url' ); |
636 | } |
637 | |
638 | // Status report for "upload to stash"/"upload from stash"/"upload by url" |
639 | if ( $this->mParams['checkstatus'] && ( $this->mParams['filekey'] || $this->mParams['url'] ) ) { |
640 | $statusKey = $this->mParams['filekey'] ?: UploadFromUrl::getCacheKey( $this->mParams ); |
641 | $progress = UploadBase::getSessionStatus( $this->getUser(), $statusKey ); |
642 | if ( !$progress ) { |
643 | $this->log->info( "Cannot check upload status due to missing upload session for {user}", |
644 | [ |
645 | 'user' => $this->getUser()->getName(), |
646 | 'filename' => $this->mParams['filename'] ?? '-', |
647 | 'filekey' => $this->mParams['filekey'] ?? '-' |
648 | ] |
649 | ); |
650 | $this->dieWithError( 'apierror-upload-missingresult', 'missingresult' ); |
651 | } elseif ( !$progress['status']->isGood() ) { |
652 | $this->dieStatusWithCode( $progress['status'], 'stashfailed' ); |
653 | } |
654 | if ( isset( $progress['status']->value['verification'] ) ) { |
655 | $this->checkVerification( $progress['status']->value['verification'] ); |
656 | } |
657 | if ( isset( $progress['status']->value['warnings'] ) ) { |
658 | $warnings = $this->transformWarnings( $progress['status']->value['warnings'] ); |
659 | if ( $warnings ) { |
660 | $progress['warnings'] = $warnings; |
661 | } |
662 | } |
663 | unset( $progress['status'] ); // remove Status object |
664 | $imageinfo = null; |
665 | if ( isset( $progress['imageinfo'] ) ) { |
666 | $imageinfo = $progress['imageinfo']; |
667 | unset( $progress['imageinfo'] ); |
668 | } |
669 | |
670 | $this->getResult()->addValue( null, $this->getModuleName(), $progress ); |
671 | // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large, |
672 | // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993). |
673 | if ( $imageinfo ) { |
674 | $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo ); |
675 | } |
676 | |
677 | return false; |
678 | } |
679 | |
680 | // The following modules all require the filename parameter to be set |
681 | if ( $this->mParams['filename'] === null ) { |
682 | $this->dieWithError( [ 'apierror-missingparam', 'filename' ] ); |
683 | } |
684 | |
685 | if ( $this->mParams['chunk'] ) { |
686 | // Chunk upload |
687 | $this->mUpload = new UploadFromChunks( $this->getUser() ); |
688 | if ( isset( $this->mParams['filekey'] ) ) { |
689 | if ( $this->mParams['offset'] === 0 ) { |
690 | $this->dieWithError( 'apierror-upload-filekeynotallowed', 'filekeynotallowed' ); |
691 | } |
692 | |
693 | // handle new chunk |
694 | $this->mUpload->continueChunks( |
695 | $this->mParams['filename'], |
696 | $this->mParams['filekey'], |
697 | $request->getUpload( 'chunk' ) |
698 | ); |
699 | } else { |
700 | if ( $this->mParams['offset'] !== 0 ) { |
701 | $this->dieWithError( 'apierror-upload-filekeyneeded', 'filekeyneeded' ); |
702 | } |
703 | |
704 | // handle first chunk |
705 | $this->mUpload->initialize( |
706 | $this->mParams['filename'], |
707 | $request->getUpload( 'chunk' ) |
708 | ); |
709 | } |
710 | } elseif ( isset( $this->mParams['filekey'] ) ) { |
711 | // Upload stashed in a previous request |
712 | if ( !UploadFromStash::isValidKey( $this->mParams['filekey'] ) ) { |
713 | $this->dieWithError( 'apierror-invalid-file-key' ); |
714 | } |
715 | |
716 | $this->mUpload = new UploadFromStash( $this->getUser() ); |
717 | // This will not download the temp file in initialize() in async mode. |
718 | // We still have enough information to call checkWarnings() and such. |
719 | $this->mUpload->initialize( |
720 | $this->mParams['filekey'], $this->mParams['filename'], !$this->mParams['async'] |
721 | ); |
722 | } elseif ( isset( $this->mParams['file'] ) ) { |
723 | // Can't async upload directly from a POSTed file, we'd have to |
724 | // stash the file and then queue the publish job. The user should |
725 | // just submit the two API queries to perform those two steps. |
726 | if ( $this->mParams['async'] ) { |
727 | $this->dieWithError( 'apierror-cannot-async-upload-file' ); |
728 | } |
729 | |
730 | $this->mUpload = new UploadFromFile(); |
731 | $this->mUpload->initialize( |
732 | $this->mParams['filename'], |
733 | $request->getUpload( 'file' ) |
734 | ); |
735 | } elseif ( isset( $this->mParams['url'] ) ) { |
736 | // Make sure upload by URL is enabled: |
737 | if ( !UploadFromUrl::isEnabled() ) { |
738 | $this->dieWithError( 'copyuploaddisabled' ); |
739 | } |
740 | |
741 | if ( !UploadFromUrl::isAllowedHost( $this->mParams['url'] ) ) { |
742 | $this->dieWithError( 'apierror-copyuploadbaddomain' ); |
743 | } |
744 | |
745 | if ( !UploadFromUrl::isAllowedUrl( $this->mParams['url'] ) ) { |
746 | $this->dieWithError( 'apierror-copyuploadbadurl' ); |
747 | } |
748 | |
749 | $this->mUpload = new UploadFromUrl; |
750 | $this->mUpload->initialize( $this->mParams['filename'], |
751 | $this->mParams['url'] ); |
752 | } |
753 | |
754 | return true; |
755 | } |
756 | |
757 | /** |
758 | * Checks that the user has permissions to perform this upload. |
759 | * Dies with usage message on inadequate permissions. |
760 | * @param User $user The user to check. |
761 | */ |
762 | protected function checkPermissions( $user ) { |
763 | // Check whether the user has the appropriate permissions to upload anyway |
764 | $permission = $this->mUpload->isAllowed( $user ); |
765 | |
766 | if ( $permission !== true ) { |
767 | if ( !$user->isNamed() ) { |
768 | $this->dieWithError( [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ] ); |
769 | } |
770 | |
771 | $this->dieStatus( User::newFatalPermissionDeniedStatus( $permission ) ); |
772 | } |
773 | |
774 | // Check blocks |
775 | if ( $user->isBlockedFromUpload() ) { |
776 | // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Block is checked and not null |
777 | $this->dieBlocked( $user->getBlock() ); |
778 | } |
779 | } |
780 | |
781 | /** |
782 | * Performs file verification, dies on error. |
783 | */ |
784 | protected function verifyUpload() { |
785 | if ( $this->mParams['chunk'] ) { |
786 | $maxSize = UploadBase::getMaxUploadSize(); |
787 | if ( $this->mParams['filesize'] > $maxSize ) { |
788 | $this->dieWithError( 'file-too-large' ); |
789 | } |
790 | if ( !$this->mUpload->getTitle() ) { |
791 | $this->dieWithError( 'illegal-filename' ); |
792 | } |
793 | // file will be assembled after having uploaded the last chunk, |
794 | // so we can only validate the name at this point |
795 | $verification = $this->mUpload->validateName(); |
796 | if ( $verification === true ) { |
797 | return; |
798 | } |
799 | } elseif ( $this->mParams['async'] && ( $this->mParams['filekey'] || $this->mParams['url'] ) ) { |
800 | // file will be assembled/downloaded in a background process, so we |
801 | // can only validate the name at this point |
802 | // file verification will happen in background process |
803 | $verification = $this->mUpload->validateName(); |
804 | if ( $verification === true ) { |
805 | return; |
806 | } |
807 | } else { |
808 | wfDebug( __METHOD__ . " about to verify" ); |
809 | |
810 | $verification = $this->mUpload->verifyUpload(); |
811 | |
812 | if ( $verification['status'] === UploadBase::OK ) { |
813 | return; |
814 | } else { |
815 | $this->log->info( "File verification of {filename} failed for {user} because {result}", |
816 | [ |
817 | 'user' => $this->getUser()->getName(), |
818 | 'resultCode' => $verification['status'], |
819 | 'result' => $this->mUpload->getVerificationErrorCode( $verification['status'] ), |
820 | 'filename' => $this->mParams['filename'] ?? '-', |
821 | 'details' => $verification['details'] ?? '' |
822 | ] |
823 | ); |
824 | } |
825 | } |
826 | |
827 | $this->checkVerification( $verification ); |
828 | } |
829 | |
830 | /** |
831 | * Performs file verification, dies on error. |
832 | * @param array $verification |
833 | * @return never |
834 | */ |
835 | protected function checkVerification( array $verification ) { |
836 | switch ( $verification['status'] ) { |
837 | // Recoverable errors |
838 | case UploadBase::MIN_LENGTH_PARTNAME: |
839 | $this->dieRecoverableError( [ 'filename-tooshort' ], 'filename' ); |
840 | // dieRecoverableError prevents continuation |
841 | case UploadBase::ILLEGAL_FILENAME: |
842 | $this->dieRecoverableError( |
843 | [ ApiMessage::create( |
844 | 'illegal-filename', null, [ 'filename' => $verification['filtered'] ] |
845 | ) ], 'filename' |
846 | ); |
847 | // dieRecoverableError prevents continuation |
848 | case UploadBase::FILENAME_TOO_LONG: |
849 | $this->dieRecoverableError( [ 'filename-toolong' ], 'filename' ); |
850 | // dieRecoverableError prevents continuation |
851 | case UploadBase::FILETYPE_MISSING: |
852 | $this->dieRecoverableError( [ 'filetype-missing' ], 'filename' ); |
853 | // dieRecoverableError prevents continuation |
854 | case UploadBase::WINDOWS_NONASCII_FILENAME: |
855 | $this->dieRecoverableError( [ 'windows-nonascii-filename' ], 'filename' ); |
856 | |
857 | // Unrecoverable errors |
858 | case UploadBase::EMPTY_FILE: |
859 | $this->dieWithError( 'empty-file' ); |
860 | // dieWithError prevents continuation |
861 | case UploadBase::FILE_TOO_LARGE: |
862 | $this->dieWithError( 'file-too-large' ); |
863 | // dieWithError prevents continuation |
864 | |
865 | case UploadBase::FILETYPE_BADTYPE: |
866 | $extradata = [ |
867 | 'filetype' => $verification['finalExt'], |
868 | 'allowed' => array_values( array_unique( |
869 | $this->getConfig()->get( MainConfigNames::FileExtensions ) ) ) |
870 | ]; |
871 | $extensions = |
872 | array_unique( $this->getConfig()->get( MainConfigNames::FileExtensions ) ); |
873 | $msg = [ |
874 | 'filetype-banned-type', |
875 | null, // filled in below |
876 | Message::listParam( $extensions, 'comma' ), |
877 | count( $extensions ), |
878 | null, // filled in below |
879 | ]; |
880 | ApiResult::setIndexedTagName( $extradata['allowed'], 'ext' ); |
881 | |
882 | if ( isset( $verification['blacklistedExt'] ) ) { |
883 | $msg[1] = Message::listParam( $verification['blacklistedExt'], 'comma' ); |
884 | $msg[4] = count( $verification['blacklistedExt'] ); |
885 | $extradata['blacklisted'] = array_values( $verification['blacklistedExt'] ); |
886 | ApiResult::setIndexedTagName( $extradata['blacklisted'], 'ext' ); |
887 | } else { |
888 | $msg[1] = $verification['finalExt']; |
889 | $msg[4] = 1; |
890 | } |
891 | |
892 | $this->dieWithError( $msg, 'filetype-banned', $extradata ); |
893 | // dieWithError prevents continuation |
894 | |
895 | case UploadBase::VERIFICATION_ERROR: |
896 | $msg = ApiMessage::create( $verification['details'], 'verification-error' ); |
897 | if ( $verification['details'][0] instanceof MessageSpecifier ) { |
898 | $details = [ $msg->getKey(), ...$msg->getParams() ]; |
899 | } else { |
900 | $details = $verification['details']; |
901 | } |
902 | ApiResult::setIndexedTagName( $details, 'detail' ); |
903 | $msg->setApiData( $msg->getApiData() + [ 'details' => $details ] ); |
904 | // @phan-suppress-next-line PhanTypeMismatchArgument |
905 | $this->dieWithError( $msg ); |
906 | // dieWithError prevents continuation |
907 | |
908 | case UploadBase::HOOK_ABORTED: |
909 | $msg = $verification['error'] === '' ? 'hookaborted' : $verification['error']; |
910 | $this->dieWithError( $msg, 'hookaborted', [ 'details' => $verification['error'] ] ); |
911 | // dieWithError prevents continuation |
912 | default: |
913 | $this->dieWithError( 'apierror-unknownerror-nocode', 'unknown-error', |
914 | [ 'details' => [ 'code' => $verification['status'] ] ] ); |
915 | } |
916 | } |
917 | |
918 | /** |
919 | * Check warnings. |
920 | * Returns a suitable array for inclusion into API results if there were warnings |
921 | * Returns the empty array if there were no warnings |
922 | * |
923 | * @return array |
924 | */ |
925 | protected function getApiWarnings() { |
926 | $warnings = UploadBase::makeWarningsSerializable( |
927 | $this->mUpload->checkWarnings( $this->getUser() ) |
928 | ); |
929 | |
930 | return $this->transformWarnings( $warnings ); |
931 | } |
932 | |
933 | protected function transformWarnings( $warnings ) { |
934 | if ( $warnings ) { |
935 | // Add indices |
936 | ApiResult::setIndexedTagName( $warnings, 'warning' ); |
937 | |
938 | if ( isset( $warnings['duplicate'] ) ) { |
939 | $dupes = array_column( $warnings['duplicate'], 'fileName' ); |
940 | ApiResult::setIndexedTagName( $dupes, 'duplicate' ); |
941 | $warnings['duplicate'] = $dupes; |
942 | } |
943 | |
944 | if ( isset( $warnings['exists'] ) ) { |
945 | $warning = $warnings['exists']; |
946 | unset( $warnings['exists'] ); |
947 | $localFile = $warning['normalizedFile'] ?? $warning['file']; |
948 | $warnings[$warning['warning']] = $localFile['fileName']; |
949 | } |
950 | |
951 | if ( isset( $warnings['no-change'] ) ) { |
952 | $file = $warnings['no-change']; |
953 | unset( $warnings['no-change'] ); |
954 | |
955 | $warnings['nochange'] = [ |
956 | 'timestamp' => wfTimestamp( TS_ISO_8601, $file['timestamp'] ) |
957 | ]; |
958 | } |
959 | |
960 | if ( isset( $warnings['duplicate-version'] ) ) { |
961 | $dupes = []; |
962 | foreach ( $warnings['duplicate-version'] as $dupe ) { |
963 | $dupes[] = [ |
964 | 'timestamp' => wfTimestamp( TS_ISO_8601, $dupe['timestamp'] ) |
965 | ]; |
966 | } |
967 | unset( $warnings['duplicate-version'] ); |
968 | |
969 | ApiResult::setIndexedTagName( $dupes, 'ver' ); |
970 | $warnings['duplicateversions'] = $dupes; |
971 | } |
972 | // We haven't downloaded the file, so this will result in an empty file warning |
973 | if ( $this->mParams['async'] && $this->mParams['url'] ) { |
974 | unset( $warnings['empty-file'] ); |
975 | } |
976 | } |
977 | |
978 | return $warnings; |
979 | } |
980 | |
981 | /** |
982 | * Handles a stash exception, giving a useful error to the user. |
983 | * @todo Internationalize the exceptions then get rid of this |
984 | * @param Exception $e |
985 | * @return StatusValue |
986 | */ |
987 | protected function handleStashException( $e ) { |
988 | $this->log->info( "Upload stashing of {filename} failed for {user} because {error}", |
989 | [ |
990 | 'user' => $this->getUser()->getName(), |
991 | 'error' => get_class( $e ), |
992 | 'filename' => $this->mParams['filename'] ?? '-', |
993 | 'filekey' => $this->mParams['filekey'] ?? '-' |
994 | ] |
995 | ); |
996 | |
997 | switch ( get_class( $e ) ) { |
998 | case UploadStashFileNotFoundException::class: |
999 | $wrap = 'apierror-stashedfilenotfound'; |
1000 | break; |
1001 | case UploadStashBadPathException::class: |
1002 | $wrap = 'apierror-stashpathinvalid'; |
1003 | break; |
1004 | case UploadStashFileException::class: |
1005 | $wrap = 'apierror-stashfilestorage'; |
1006 | break; |
1007 | case UploadStashZeroLengthFileException::class: |
1008 | $wrap = 'apierror-stashzerolength'; |
1009 | break; |
1010 | case UploadStashNotLoggedInException::class: |
1011 | return StatusValue::newFatal( ApiMessage::create( |
1012 | [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ], 'stashnotloggedin' |
1013 | ) ); |
1014 | case UploadStashWrongOwnerException::class: |
1015 | $wrap = 'apierror-stashwrongowner'; |
1016 | break; |
1017 | case UploadStashNoSuchKeyException::class: |
1018 | $wrap = 'apierror-stashnosuchfilekey'; |
1019 | break; |
1020 | default: |
1021 | $wrap = [ 'uploadstash-exception', get_class( $e ) ]; |
1022 | break; |
1023 | } |
1024 | return StatusValue::newFatal( |
1025 | $this->getErrorFormatter()->getMessageFromException( $e, [ 'wrap' => $wrap ] ) |
1026 | ); |
1027 | } |
1028 | |
1029 | /** |
1030 | * Perform the actual upload. Returns a suitable result array on success; |
1031 | * dies on failure. |
1032 | * |
1033 | * @param array $warnings Array of Api upload warnings |
1034 | * @return array |
1035 | */ |
1036 | protected function performUpload( $warnings ) { |
1037 | // Use comment as initial page text by default |
1038 | $this->mParams['text'] ??= $this->mParams['comment']; |
1039 | |
1040 | /** @var LocalFile $file */ |
1041 | $file = $this->mUpload->getLocalFile(); |
1042 | $user = $this->getUser(); |
1043 | $title = $file->getTitle(); |
1044 | |
1045 | // for preferences mode, we want to watch if 'watchdefault' is set, |
1046 | // or if the *file* doesn't exist, and either 'watchuploads' or |
1047 | // 'watchcreations' is set. But getWatchlistValue()'s automatic |
1048 | // handling checks if the *title* exists or not, so we need to check |
1049 | // all three preferences manually. |
1050 | $watch = $this->getWatchlistValue( |
1051 | $this->mParams['watchlist'], $title, $user, 'watchdefault' |
1052 | ); |
1053 | |
1054 | if ( !$watch && $this->mParams['watchlist'] == 'preferences' && !$file->exists() ) { |
1055 | $watch = ( |
1056 | $this->getWatchlistValue( 'preferences', $title, $user, 'watchuploads' ) || |
1057 | $this->getWatchlistValue( 'preferences', $title, $user, 'watchcreations' ) |
1058 | ); |
1059 | } |
1060 | $watchlistExpiry = $this->getExpiryFromParams( $this->mParams ); |
1061 | |
1062 | // Deprecated parameters |
1063 | if ( $this->mParams['watch'] ) { |
1064 | $watch = true; |
1065 | } |
1066 | |
1067 | if ( $this->mParams['tags'] ) { |
1068 | $status = ChangeTags::canAddTagsAccompanyingChange( $this->mParams['tags'], $this->getAuthority() ); |
1069 | if ( !$status->isOK() ) { |
1070 | $this->dieStatus( $status ); |
1071 | } |
1072 | } |
1073 | |
1074 | // No errors, no warnings: do the upload |
1075 | $result = []; |
1076 | if ( $this->mParams['async'] ) { |
1077 | // Only stash uploads and copy uploads support async |
1078 | if ( $this->mParams['filekey'] ) { |
1079 | $job = new PublishStashedFileJob( |
1080 | [ |
1081 | 'filename' => $this->mParams['filename'], |
1082 | 'filekey' => $this->mParams['filekey'], |
1083 | 'comment' => $this->mParams['comment'], |
1084 | 'tags' => $this->mParams['tags'] ?? [], |
1085 | 'text' => $this->mParams['text'], |
1086 | 'watch' => $watch, |
1087 | 'watchlistexpiry' => $watchlistExpiry, |
1088 | 'session' => $this->getContext()->exportSession(), |
1089 | 'ignorewarnings' => $this->mParams['ignorewarnings'] |
1090 | ] |
1091 | ); |
1092 | } elseif ( $this->mParams['url'] ) { |
1093 | $job = new UploadFromUrlJob( |
1094 | [ |
1095 | 'filename' => $this->mParams['filename'], |
1096 | 'url' => $this->mParams['url'], |
1097 | 'comment' => $this->mParams['comment'], |
1098 | 'tags' => $this->mParams['tags'] ?? [], |
1099 | 'text' => $this->mParams['text'], |
1100 | 'watch' => $watch, |
1101 | 'watchlistexpiry' => $watchlistExpiry, |
1102 | 'session' => $this->getContext()->exportSession(), |
1103 | 'ignorewarnings' => $this->mParams['ignorewarnings'] |
1104 | ] |
1105 | ); |
1106 | } else { |
1107 | $this->dieWithError( 'apierror-no-async-support', 'publishfailed' ); |
1108 | // We will never reach this, but it's here to help phan figure out |
1109 | // $job is never null |
1110 | // @phan-suppress-next-line PhanPluginUnreachableCode On purpose |
1111 | return []; |
1112 | } |
1113 | $cacheKey = $job->getCacheKey(); |
1114 | // Check if an upload is already in progress. |
1115 | // the result can be Poll / Failure / Success |
1116 | $progress = UploadBase::getSessionStatus( $this->getUser(), $cacheKey ); |
1117 | if ( $progress && $progress['result'] === 'Poll' ) { |
1118 | $this->dieWithError( 'apierror-upload-inprogress', 'publishfailed' ); |
1119 | } |
1120 | UploadBase::setSessionStatus( |
1121 | $this->getUser(), |
1122 | $cacheKey, |
1123 | [ 'result' => 'Poll', 'stage' => 'queued', 'status' => Status::newGood() ] |
1124 | ); |
1125 | |
1126 | $this->jobQueueGroup->push( $job ); |
1127 | $this->log->info( "Sending publish job of {filename} for {user}", |
1128 | [ |
1129 | 'user' => $this->getUser()->getName(), |
1130 | 'filename' => $this->mParams['filename'] ?? '-' |
1131 | ] |
1132 | ); |
1133 | $result['result'] = 'Poll'; |
1134 | $result['stage'] = 'queued'; |
1135 | } else { |
1136 | /** @var Status $status */ |
1137 | $status = $this->mUpload->performUpload( |
1138 | $this->mParams['comment'], |
1139 | $this->mParams['text'], |
1140 | $watch, |
1141 | $this->getUser(), |
1142 | $this->mParams['tags'] ?? [], |
1143 | $watchlistExpiry |
1144 | ); |
1145 | |
1146 | if ( !$status->isGood() ) { |
1147 | $this->log->info( "Non-async API upload publish failed for {user} because {status}", |
1148 | [ |
1149 | 'user' => $this->getUser()->getName(), |
1150 | 'filename' => $this->mParams['filename'] ?? '-', |
1151 | 'filekey' => $this->mParams['filekey'] ?? '-', |
1152 | 'status' => (string)$status |
1153 | ] |
1154 | ); |
1155 | $this->dieRecoverableError( $status->getErrors() ); |
1156 | } |
1157 | $result['result'] = 'Success'; |
1158 | } |
1159 | |
1160 | $result['filename'] = $file->getName(); |
1161 | if ( $warnings && count( $warnings ) > 0 ) { |
1162 | $result['warnings'] = $warnings; |
1163 | } |
1164 | |
1165 | return $result; |
1166 | } |
1167 | |
1168 | public function mustBePosted() { |
1169 | return true; |
1170 | } |
1171 | |
1172 | public function isWriteMode() { |
1173 | return true; |
1174 | } |
1175 | |
1176 | public function getAllowedParams() { |
1177 | $params = [ |
1178 | 'filename' => [ |
1179 | ParamValidator::PARAM_TYPE => 'string', |
1180 | ], |
1181 | 'comment' => [ |
1182 | ParamValidator::PARAM_DEFAULT => '' |
1183 | ], |
1184 | 'tags' => [ |
1185 | ParamValidator::PARAM_TYPE => 'tags', |
1186 | ParamValidator::PARAM_ISMULTI => true, |
1187 | ], |
1188 | 'text' => [ |
1189 | ParamValidator::PARAM_TYPE => 'text', |
1190 | ], |
1191 | 'watch' => [ |
1192 | ParamValidator::PARAM_DEFAULT => false, |
1193 | ParamValidator::PARAM_DEPRECATED => true, |
1194 | ], |
1195 | ]; |
1196 | |
1197 | // Params appear in the docs in the order they are defined, |
1198 | // which is why this is here and not at the bottom. |
1199 | $params += $this->getWatchlistParams( [ |
1200 | 'watch', |
1201 | 'preferences', |
1202 | 'nochange', |
1203 | ] ); |
1204 | |
1205 | $params += [ |
1206 | 'ignorewarnings' => false, |
1207 | 'file' => [ |
1208 | ParamValidator::PARAM_TYPE => 'upload', |
1209 | ], |
1210 | 'url' => null, |
1211 | 'filekey' => null, |
1212 | 'sessionkey' => [ |
1213 | ParamValidator::PARAM_DEPRECATED => true, |
1214 | ], |
1215 | 'stash' => false, |
1216 | |
1217 | 'filesize' => [ |
1218 | ParamValidator::PARAM_TYPE => 'integer', |
1219 | IntegerDef::PARAM_MIN => 0, |
1220 | IntegerDef::PARAM_MAX => UploadBase::getMaxUploadSize(), |
1221 | ], |
1222 | 'offset' => [ |
1223 | ParamValidator::PARAM_TYPE => 'integer', |
1224 | IntegerDef::PARAM_MIN => 0, |
1225 | ], |
1226 | 'chunk' => [ |
1227 | ParamValidator::PARAM_TYPE => 'upload', |
1228 | ], |
1229 | |
1230 | 'async' => false, |
1231 | 'checkstatus' => false, |
1232 | ]; |
1233 | |
1234 | return $params; |
1235 | } |
1236 | |
1237 | public function needsToken() { |
1238 | return 'csrf'; |
1239 | } |
1240 | |
1241 | protected function getExamplesMessages() { |
1242 | return [ |
1243 | 'action=upload&filename=Wiki.png' . |
1244 | '&url=http%3A//upload.wikimedia.org/wikipedia/en/b/bc/Wiki.png&token=123ABC' |
1245 | => 'apihelp-upload-example-url', |
1246 | 'action=upload&filename=Wiki.png&filekey=filekey&ignorewarnings=1&token=123ABC' |
1247 | => 'apihelp-upload-example-filekey', |
1248 | ]; |
1249 | } |
1250 | |
1251 | public function getHelpUrls() { |
1252 | return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Upload'; |
1253 | } |
1254 | } |