ApiUpload.php 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934
  1. <?php
  2. /**
  3. *
  4. *
  5. * Created on Aug 21, 2008
  6. *
  7. * Copyright © 2008 - 2010 Bryan Tong Minh <Bryan.TongMinh@Gmail.com>
  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. * @ingroup API
  28. */
  29. class ApiUpload extends ApiBase {
  30. /** @var UploadBase|UploadFromChunks */
  31. protected $mUpload = null;
  32. protected $mParams;
  33. public function execute() {
  34. // Check whether upload is enabled
  35. if ( !UploadBase::isEnabled() ) {
  36. $this->dieWithError( 'uploaddisabled' );
  37. }
  38. $user = $this->getUser();
  39. // Parameter handling
  40. $this->mParams = $this->extractRequestParams();
  41. $request = $this->getMain()->getRequest();
  42. // Check if async mode is actually supported (jobs done in cli mode)
  43. $this->mParams['async'] = ( $this->mParams['async'] &&
  44. $this->getConfig()->get( 'EnableAsyncUploads' ) );
  45. // Add the uploaded file to the params array
  46. $this->mParams['file'] = $request->getFileName( 'file' );
  47. $this->mParams['chunk'] = $request->getFileName( 'chunk' );
  48. // Copy the session key to the file key, for backward compatibility.
  49. if ( !$this->mParams['filekey'] && $this->mParams['sessionkey'] ) {
  50. $this->mParams['filekey'] = $this->mParams['sessionkey'];
  51. }
  52. // Select an upload module
  53. try {
  54. if ( !$this->selectUploadModule() ) {
  55. return; // not a true upload, but a status request or similar
  56. } elseif ( !isset( $this->mUpload ) ) {
  57. $this->dieDebug( __METHOD__, 'No upload module set' );
  58. }
  59. } catch ( UploadStashException $e ) { // XXX: don't spam exception log
  60. $this->dieStatus( $this->handleStashException( $e ) );
  61. }
  62. // First check permission to upload
  63. $this->checkPermissions( $user );
  64. // Fetch the file (usually a no-op)
  65. /** @var Status $status */
  66. $status = $this->mUpload->fetchFile();
  67. if ( !$status->isGood() ) {
  68. $this->dieStatus( $status );
  69. }
  70. // Check if the uploaded file is sane
  71. if ( $this->mParams['chunk'] ) {
  72. $maxSize = UploadBase::getMaxUploadSize();
  73. if ( $this->mParams['filesize'] > $maxSize ) {
  74. $this->dieWithError( 'file-too-large' );
  75. }
  76. if ( !$this->mUpload->getTitle() ) {
  77. $this->dieWithError( 'illegal-filename' );
  78. }
  79. } elseif ( $this->mParams['async'] && $this->mParams['filekey'] ) {
  80. // defer verification to background process
  81. } else {
  82. wfDebug( __METHOD__ . " about to verify\n" );
  83. $this->verifyUpload();
  84. }
  85. // Check if the user has the rights to modify or overwrite the requested title
  86. // (This check is irrelevant if stashing is already requested, since the errors
  87. // can always be fixed by changing the title)
  88. if ( !$this->mParams['stash'] ) {
  89. $permErrors = $this->mUpload->verifyTitlePermissions( $user );
  90. if ( $permErrors !== true ) {
  91. $this->dieRecoverableError( $permErrors, 'filename' );
  92. }
  93. }
  94. // Get the result based on the current upload context:
  95. try {
  96. $result = $this->getContextResult();
  97. } catch ( UploadStashException $e ) { // XXX: don't spam exception log
  98. $this->dieStatus( $this->handleStashException( $e ) );
  99. }
  100. $this->getResult()->addValue( null, $this->getModuleName(), $result );
  101. // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
  102. // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
  103. if ( $result['result'] === 'Success' ) {
  104. $imageinfo = $this->mUpload->getImageInfo( $this->getResult() );
  105. $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
  106. }
  107. // Cleanup any temporary mess
  108. $this->mUpload->cleanupTempFile();
  109. }
  110. /**
  111. * Get an upload result based on upload context
  112. * @return array
  113. */
  114. private function getContextResult() {
  115. $warnings = $this->getApiWarnings();
  116. if ( $warnings && !$this->mParams['ignorewarnings'] ) {
  117. // Get warnings formatted in result array format
  118. return $this->getWarningsResult( $warnings );
  119. } elseif ( $this->mParams['chunk'] ) {
  120. // Add chunk, and get result
  121. return $this->getChunkResult( $warnings );
  122. } elseif ( $this->mParams['stash'] ) {
  123. // Stash the file and get stash result
  124. return $this->getStashResult( $warnings );
  125. }
  126. // Check throttle after we've handled warnings
  127. if ( UploadBase::isThrottled( $this->getUser() )
  128. ) {
  129. $this->dieWithError( 'apierror-ratelimited' );
  130. }
  131. // This is the most common case -- a normal upload with no warnings
  132. // performUpload will return a formatted properly for the API with status
  133. return $this->performUpload( $warnings );
  134. }
  135. /**
  136. * Get Stash Result, throws an exception if the file could not be stashed.
  137. * @param array $warnings Array of Api upload warnings
  138. * @return array
  139. */
  140. private function getStashResult( $warnings ) {
  141. $result = [];
  142. $result['result'] = 'Success';
  143. if ( $warnings && count( $warnings ) > 0 ) {
  144. $result['warnings'] = $warnings;
  145. }
  146. // Some uploads can request they be stashed, so as not to publish them immediately.
  147. // In this case, a failure to stash ought to be fatal
  148. $this->performStash( 'critical', $result );
  149. return $result;
  150. }
  151. /**
  152. * Get Warnings Result
  153. * @param array $warnings Array of Api upload warnings
  154. * @return array
  155. */
  156. private function getWarningsResult( $warnings ) {
  157. $result = [];
  158. $result['result'] = 'Warning';
  159. $result['warnings'] = $warnings;
  160. // in case the warnings can be fixed with some further user action, let's stash this upload
  161. // and return a key they can use to restart it
  162. $this->performStash( 'optional', $result );
  163. return $result;
  164. }
  165. /**
  166. * Get the result of a chunk upload.
  167. * @param array $warnings Array of Api upload warnings
  168. * @return array
  169. */
  170. private function getChunkResult( $warnings ) {
  171. $result = [];
  172. if ( $warnings && count( $warnings ) > 0 ) {
  173. $result['warnings'] = $warnings;
  174. }
  175. $request = $this->getMain()->getRequest();
  176. $chunkPath = $request->getFileTempname( 'chunk' );
  177. $chunkSize = $request->getUpload( 'chunk' )->getSize();
  178. $totalSoFar = $this->mParams['offset'] + $chunkSize;
  179. $minChunkSize = $this->getConfig()->get( 'MinUploadChunkSize' );
  180. // Sanity check sizing
  181. if ( $totalSoFar > $this->mParams['filesize'] ) {
  182. $this->dieWithError( 'apierror-invalid-chunk' );
  183. }
  184. // Enforce minimum chunk size
  185. if ( $totalSoFar != $this->mParams['filesize'] && $chunkSize < $minChunkSize ) {
  186. $this->dieWithError( [ 'apierror-chunk-too-small', Message::numParam( $minChunkSize ) ] );
  187. }
  188. if ( $this->mParams['offset'] == 0 ) {
  189. $filekey = $this->performStash( 'critical' );
  190. } else {
  191. $filekey = $this->mParams['filekey'];
  192. // Don't allow further uploads to an already-completed session
  193. $progress = UploadBase::getSessionStatus( $this->getUser(), $filekey );
  194. if ( !$progress ) {
  195. // Probably can't get here, but check anyway just in case
  196. $this->dieWithError( 'apierror-stashfailed-nosession', 'stashfailed' );
  197. } elseif ( $progress['result'] !== 'Continue' || $progress['stage'] !== 'uploading' ) {
  198. $this->dieWithError( 'apierror-stashfailed-complete', 'stashfailed' );
  199. }
  200. $status = $this->mUpload->addChunk(
  201. $chunkPath, $chunkSize, $this->mParams['offset'] );
  202. if ( !$status->isGood() ) {
  203. $extradata = [
  204. 'offset' => $this->mUpload->getOffset(),
  205. ];
  206. $this->dieStatusWithCode( $status, 'stashfailed', $extradata );
  207. }
  208. }
  209. // Check we added the last chunk:
  210. if ( $totalSoFar == $this->mParams['filesize'] ) {
  211. if ( $this->mParams['async'] ) {
  212. UploadBase::setSessionStatus(
  213. $this->getUser(),
  214. $filekey,
  215. [ 'result' => 'Poll',
  216. 'stage' => 'queued', 'status' => Status::newGood() ]
  217. );
  218. JobQueueGroup::singleton()->push( new AssembleUploadChunksJob(
  219. Title::makeTitle( NS_FILE, $filekey ),
  220. [
  221. 'filename' => $this->mParams['filename'],
  222. 'filekey' => $filekey,
  223. 'session' => $this->getContext()->exportSession()
  224. ]
  225. ) );
  226. $result['result'] = 'Poll';
  227. $result['stage'] = 'queued';
  228. } else {
  229. $status = $this->mUpload->concatenateChunks();
  230. if ( !$status->isGood() ) {
  231. UploadBase::setSessionStatus(
  232. $this->getUser(),
  233. $filekey,
  234. [ 'result' => 'Failure', 'stage' => 'assembling', 'status' => $status ]
  235. );
  236. $this->dieStatusWithCode( $status, 'stashfailed' );
  237. }
  238. // We can only get warnings like 'duplicate' after concatenating the chunks
  239. $warnings = $this->getApiWarnings();
  240. if ( $warnings ) {
  241. $result['warnings'] = $warnings;
  242. }
  243. // The fully concatenated file has a new filekey. So remove
  244. // the old filekey and fetch the new one.
  245. UploadBase::setSessionStatus( $this->getUser(), $filekey, false );
  246. $this->mUpload->stash->removeFile( $filekey );
  247. $filekey = $this->mUpload->getStashFile()->getFileKey();
  248. $result['result'] = 'Success';
  249. }
  250. } else {
  251. UploadBase::setSessionStatus(
  252. $this->getUser(),
  253. $filekey,
  254. [
  255. 'result' => 'Continue',
  256. 'stage' => 'uploading',
  257. 'offset' => $totalSoFar,
  258. 'status' => Status::newGood(),
  259. ]
  260. );
  261. $result['result'] = 'Continue';
  262. $result['offset'] = $totalSoFar;
  263. }
  264. $result['filekey'] = $filekey;
  265. return $result;
  266. }
  267. /**
  268. * Stash the file and add the file key, or error information if it fails, to the data.
  269. *
  270. * @param string $failureMode What to do on failure to stash:
  271. * - When 'critical', use dieStatus() to produce an error response and throw an exception.
  272. * Use this when stashing the file was the primary purpose of the API request.
  273. * - When 'optional', only add a 'stashfailed' key to the data and return null.
  274. * Use this when some error happened for a non-stash upload and we're stashing the file
  275. * only to save the client the trouble of re-uploading it.
  276. * @param array &$data API result to which to add the information
  277. * @return string|null File key
  278. */
  279. private function performStash( $failureMode, &$data = null ) {
  280. $isPartial = (bool)$this->mParams['chunk'];
  281. try {
  282. $status = $this->mUpload->tryStashFile( $this->getUser(), $isPartial );
  283. if ( $status->isGood() && !$status->getValue() ) {
  284. // Not actually a 'good' status...
  285. $status->fatal( new ApiMessage( 'apierror-stashinvalidfile', 'stashfailed' ) );
  286. }
  287. } catch ( Exception $e ) {
  288. $debugMessage = 'Stashing temporary file failed: ' . get_class( $e ) . ' ' . $e->getMessage();
  289. wfDebug( __METHOD__ . ' ' . $debugMessage . "\n" );
  290. $status = Status::newFatal( $this->getErrorFormatter()->getMessageFromException(
  291. $e, [ 'wrap' => new ApiMessage( 'apierror-stashexception', 'stashfailed' ) ]
  292. ) );
  293. }
  294. if ( $status->isGood() ) {
  295. $stashFile = $status->getValue();
  296. $data['filekey'] = $stashFile->getFileKey();
  297. // Backwards compatibility
  298. $data['sessionkey'] = $data['filekey'];
  299. return $data['filekey'];
  300. }
  301. if ( $status->getMessage()->getKey() === 'uploadstash-exception' ) {
  302. // The exceptions thrown by upload stash code and pretty silly and UploadBase returns poor
  303. // Statuses for it. Just extract the exception details and parse them ourselves.
  304. list( $exceptionType, $message ) = $status->getMessage()->getParams();
  305. $debugMessage = 'Stashing temporary file failed: ' . $exceptionType . ' ' . $message;
  306. wfDebug( __METHOD__ . ' ' . $debugMessage . "\n" );
  307. }
  308. // Bad status
  309. if ( $failureMode !== 'optional' ) {
  310. $this->dieStatus( $status );
  311. } else {
  312. $data['stasherrors'] = $this->getErrorFormatter()->arrayFromStatus( $status );
  313. return null;
  314. }
  315. }
  316. /**
  317. * Throw an error that the user can recover from by providing a better
  318. * value for $parameter
  319. *
  320. * @param array $errors Array of Message objects, message keys, key+param
  321. * arrays, or StatusValue::getErrors()-style arrays
  322. * @param string|null $parameter Parameter that needs revising
  323. * @throws ApiUsageException
  324. */
  325. private function dieRecoverableError( $errors, $parameter = null ) {
  326. $this->performStash( 'optional', $data );
  327. if ( $parameter ) {
  328. $data['invalidparameter'] = $parameter;
  329. }
  330. $sv = StatusValue::newGood();
  331. foreach ( $errors as $error ) {
  332. $msg = ApiMessage::create( $error );
  333. $msg->setApiData( $msg->getApiData() + $data );
  334. $sv->fatal( $msg );
  335. }
  336. $this->dieStatus( $sv );
  337. }
  338. /**
  339. * Like dieStatus(), but always uses $overrideCode for the error code, unless the code comes from
  340. * IApiMessage.
  341. *
  342. * @param Status $status
  343. * @param string $overrideCode Error code to use if there isn't one from IApiMessage
  344. * @param array|null $moreExtraData
  345. * @throws ApiUsageException
  346. */
  347. public function dieStatusWithCode( $status, $overrideCode, $moreExtraData = null ) {
  348. $sv = StatusValue::newGood();
  349. foreach ( $status->getErrors() as $error ) {
  350. $msg = ApiMessage::create( $error, $overrideCode );
  351. if ( $moreExtraData ) {
  352. $msg->setApiData( $msg->getApiData() + $moreExtraData );
  353. }
  354. $sv->fatal( $msg );
  355. }
  356. $this->dieStatus( $sv );
  357. }
  358. /**
  359. * Select an upload module and set it to mUpload. Dies on failure. If the
  360. * request was a status request and not a true upload, returns false;
  361. * otherwise true
  362. *
  363. * @return bool
  364. */
  365. protected function selectUploadModule() {
  366. $request = $this->getMain()->getRequest();
  367. // chunk or one and only one of the following parameters is needed
  368. if ( !$this->mParams['chunk'] ) {
  369. $this->requireOnlyOneParameter( $this->mParams,
  370. 'filekey', 'file', 'url' );
  371. }
  372. // Status report for "upload to stash"/"upload from stash"
  373. if ( $this->mParams['filekey'] && $this->mParams['checkstatus'] ) {
  374. $progress = UploadBase::getSessionStatus( $this->getUser(), $this->mParams['filekey'] );
  375. if ( !$progress ) {
  376. $this->dieWithError( 'api-upload-missingresult', 'missingresult' );
  377. } elseif ( !$progress['status']->isGood() ) {
  378. $this->dieStatusWithCode( $progress['status'], 'stashfailed' );
  379. }
  380. if ( isset( $progress['status']->value['verification'] ) ) {
  381. $this->checkVerification( $progress['status']->value['verification'] );
  382. }
  383. if ( isset( $progress['status']->value['warnings'] ) ) {
  384. $warnings = $this->transformWarnings( $progress['status']->value['warnings'] );
  385. if ( $warnings ) {
  386. $progress['warnings'] = $warnings;
  387. }
  388. }
  389. unset( $progress['status'] ); // remove Status object
  390. $imageinfo = null;
  391. if ( isset( $progress['imageinfo'] ) ) {
  392. $imageinfo = $progress['imageinfo'];
  393. unset( $progress['imageinfo'] );
  394. }
  395. $this->getResult()->addValue( null, $this->getModuleName(), $progress );
  396. // Add 'imageinfo' in a separate addValue() call. File metadata can be unreasonably large,
  397. // so otherwise when it exceeded $wgAPIMaxResultSize, no result would be returned (T143993).
  398. if ( $imageinfo ) {
  399. $this->getResult()->addValue( $this->getModuleName(), 'imageinfo', $imageinfo );
  400. }
  401. return false;
  402. }
  403. // The following modules all require the filename parameter to be set
  404. if ( is_null( $this->mParams['filename'] ) ) {
  405. $this->dieWithError( [ 'apierror-missingparam', 'filename' ] );
  406. }
  407. if ( $this->mParams['chunk'] ) {
  408. // Chunk upload
  409. $this->mUpload = new UploadFromChunks( $this->getUser() );
  410. if ( isset( $this->mParams['filekey'] ) ) {
  411. if ( $this->mParams['offset'] === 0 ) {
  412. $this->dieWithError( 'apierror-upload-filekeynotallowed', 'filekeynotallowed' );
  413. }
  414. // handle new chunk
  415. $this->mUpload->continueChunks(
  416. $this->mParams['filename'],
  417. $this->mParams['filekey'],
  418. $request->getUpload( 'chunk' )
  419. );
  420. } else {
  421. if ( $this->mParams['offset'] !== 0 ) {
  422. $this->dieWithError( 'apierror-upload-filekeyneeded', 'filekeyneeded' );
  423. }
  424. // handle first chunk
  425. $this->mUpload->initialize(
  426. $this->mParams['filename'],
  427. $request->getUpload( 'chunk' )
  428. );
  429. }
  430. } elseif ( isset( $this->mParams['filekey'] ) ) {
  431. // Upload stashed in a previous request
  432. if ( !UploadFromStash::isValidKey( $this->mParams['filekey'] ) ) {
  433. $this->dieWithError( 'apierror-invalid-file-key' );
  434. }
  435. $this->mUpload = new UploadFromStash( $this->getUser() );
  436. // This will not download the temp file in initialize() in async mode.
  437. // We still have enough information to call checkWarnings() and such.
  438. $this->mUpload->initialize(
  439. $this->mParams['filekey'], $this->mParams['filename'], !$this->mParams['async']
  440. );
  441. } elseif ( isset( $this->mParams['file'] ) ) {
  442. // Can't async upload directly from a POSTed file, we'd have to
  443. // stash the file and then queue the publish job. The user should
  444. // just submit the two API queries to perform those two steps.
  445. if ( $this->mParams['async'] ) {
  446. $this->dieWithError( 'apierror-cannot-async-upload-file' );
  447. }
  448. $this->mUpload = new UploadFromFile();
  449. $this->mUpload->initialize(
  450. $this->mParams['filename'],
  451. $request->getUpload( 'file' )
  452. );
  453. } elseif ( isset( $this->mParams['url'] ) ) {
  454. // Make sure upload by URL is enabled:
  455. if ( !UploadFromUrl::isEnabled() ) {
  456. $this->dieWithError( 'copyuploaddisabled' );
  457. }
  458. if ( !UploadFromUrl::isAllowedHost( $this->mParams['url'] ) ) {
  459. $this->dieWithError( 'apierror-copyuploadbaddomain' );
  460. }
  461. if ( !UploadFromUrl::isAllowedUrl( $this->mParams['url'] ) ) {
  462. $this->dieWithError( 'apierror-copyuploadbadurl' );
  463. }
  464. $this->mUpload = new UploadFromUrl;
  465. $this->mUpload->initialize( $this->mParams['filename'],
  466. $this->mParams['url'] );
  467. }
  468. return true;
  469. }
  470. /**
  471. * Checks that the user has permissions to perform this upload.
  472. * Dies with usage message on inadequate permissions.
  473. * @param User $user The user to check.
  474. */
  475. protected function checkPermissions( $user ) {
  476. // Check whether the user has the appropriate permissions to upload anyway
  477. $permission = $this->mUpload->isAllowed( $user );
  478. if ( $permission !== true ) {
  479. if ( !$user->isLoggedIn() ) {
  480. $this->dieWithError( [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ] );
  481. }
  482. $this->dieStatus( User::newFatalPermissionDeniedStatus( $permission ) );
  483. }
  484. // Check blocks
  485. if ( $user->isBlocked() ) {
  486. $this->dieBlocked( $user->getBlock() );
  487. }
  488. // Global blocks
  489. if ( $user->isBlockedGlobally() ) {
  490. $this->dieBlocked( $user->getGlobalBlock() );
  491. }
  492. }
  493. /**
  494. * Performs file verification, dies on error.
  495. */
  496. protected function verifyUpload() {
  497. $verification = $this->mUpload->verifyUpload();
  498. if ( $verification['status'] === UploadBase::OK ) {
  499. return;
  500. }
  501. $this->checkVerification( $verification );
  502. }
  503. /**
  504. * Performs file verification, dies on error.
  505. * @param array $verification
  506. */
  507. protected function checkVerification( array $verification ) {
  508. switch ( $verification['status'] ) {
  509. // Recoverable errors
  510. case UploadBase::MIN_LENGTH_PARTNAME:
  511. $this->dieRecoverableError( [ 'filename-tooshort' ], 'filename' );
  512. break;
  513. case UploadBase::ILLEGAL_FILENAME:
  514. $this->dieRecoverableError(
  515. [ ApiMessage::create(
  516. 'illegal-filename', null, [ 'filename' => $verification['filtered'] ]
  517. ) ], 'filename'
  518. );
  519. break;
  520. case UploadBase::FILENAME_TOO_LONG:
  521. $this->dieRecoverableError( [ 'filename-toolong' ], 'filename' );
  522. break;
  523. case UploadBase::FILETYPE_MISSING:
  524. $this->dieRecoverableError( [ 'filetype-missing' ], 'filename' );
  525. break;
  526. case UploadBase::WINDOWS_NONASCII_FILENAME:
  527. $this->dieRecoverableError( [ 'windows-nonascii-filename' ], 'filename' );
  528. break;
  529. // Unrecoverable errors
  530. case UploadBase::EMPTY_FILE:
  531. $this->dieWithError( 'empty-file' );
  532. break;
  533. case UploadBase::FILE_TOO_LARGE:
  534. $this->dieWithError( 'file-too-large' );
  535. break;
  536. case UploadBase::FILETYPE_BADTYPE:
  537. $extradata = [
  538. 'filetype' => $verification['finalExt'],
  539. 'allowed' => array_values( array_unique( $this->getConfig()->get( 'FileExtensions' ) ) )
  540. ];
  541. $extensions = array_unique( $this->getConfig()->get( 'FileExtensions' ) );
  542. $msg = [
  543. 'filetype-banned-type',
  544. null, // filled in below
  545. Message::listParam( $extensions, 'comma' ),
  546. count( $extensions ),
  547. null, // filled in below
  548. ];
  549. ApiResult::setIndexedTagName( $extradata['allowed'], 'ext' );
  550. if ( isset( $verification['blacklistedExt'] ) ) {
  551. $msg[1] = Message::listParam( $verification['blacklistedExt'], 'comma' );
  552. $msg[4] = count( $verification['blacklistedExt'] );
  553. $extradata['blacklisted'] = array_values( $verification['blacklistedExt'] );
  554. ApiResult::setIndexedTagName( $extradata['blacklisted'], 'ext' );
  555. } else {
  556. $msg[1] = $verification['finalExt'];
  557. $msg[4] = 1;
  558. }
  559. $this->dieWithError( $msg, 'filetype-banned', $extradata );
  560. break;
  561. case UploadBase::VERIFICATION_ERROR:
  562. $msg = ApiMessage::create( $verification['details'], 'verification-error' );
  563. if ( $verification['details'][0] instanceof MessageSpecifier ) {
  564. $details = array_merge( [ $msg->getKey() ], $msg->getParams() );
  565. } else {
  566. $details = $verification['details'];
  567. }
  568. ApiResult::setIndexedTagName( $details, 'detail' );
  569. $msg->setApiData( $msg->getApiData() + [ 'details' => $details ] );
  570. $this->dieWithError( $msg );
  571. break;
  572. case UploadBase::HOOK_ABORTED:
  573. $msg = $verification['error'] === '' ? 'hookaborted' : $verification['error'];
  574. $this->dieWithError( $msg, 'hookaborted', [ 'details' => $verification['error'] ] );
  575. break;
  576. default:
  577. $this->dieWithError( 'apierror-unknownerror-nocode', 'unknown-error',
  578. [ 'details' => [ 'code' => $verification['status'] ] ] );
  579. break;
  580. }
  581. }
  582. /**
  583. * Check warnings.
  584. * Returns a suitable array for inclusion into API results if there were warnings
  585. * Returns the empty array if there were no warnings
  586. *
  587. * @return array
  588. */
  589. protected function getApiWarnings() {
  590. $warnings = $this->mUpload->checkWarnings();
  591. return $this->transformWarnings( $warnings );
  592. }
  593. protected function transformWarnings( $warnings ) {
  594. if ( $warnings ) {
  595. // Add indices
  596. ApiResult::setIndexedTagName( $warnings, 'warning' );
  597. if ( isset( $warnings['duplicate'] ) ) {
  598. $dupes = [];
  599. /** @var File $dupe */
  600. foreach ( $warnings['duplicate'] as $dupe ) {
  601. $dupes[] = $dupe->getName();
  602. }
  603. ApiResult::setIndexedTagName( $dupes, 'duplicate' );
  604. $warnings['duplicate'] = $dupes;
  605. }
  606. if ( isset( $warnings['exists'] ) ) {
  607. $warning = $warnings['exists'];
  608. unset( $warnings['exists'] );
  609. /** @var LocalFile $localFile */
  610. $localFile = isset( $warning['normalizedFile'] )
  611. ? $warning['normalizedFile']
  612. : $warning['file'];
  613. $warnings[$warning['warning']] = $localFile->getName();
  614. }
  615. if ( isset( $warnings['no-change'] ) ) {
  616. /** @var File $file */
  617. $file = $warnings['no-change'];
  618. unset( $warnings['no-change'] );
  619. $warnings['nochange'] = [
  620. 'timestamp' => wfTimestamp( TS_ISO_8601, $file->getTimestamp() )
  621. ];
  622. }
  623. if ( isset( $warnings['duplicate-version'] ) ) {
  624. $dupes = [];
  625. /** @var File $dupe */
  626. foreach ( $warnings['duplicate-version'] as $dupe ) {
  627. $dupes[] = [
  628. 'timestamp' => wfTimestamp( TS_ISO_8601, $dupe->getTimestamp() )
  629. ];
  630. }
  631. unset( $warnings['duplicate-version'] );
  632. ApiResult::setIndexedTagName( $dupes, 'ver' );
  633. $warnings['duplicateversions'] = $dupes;
  634. }
  635. }
  636. return $warnings;
  637. }
  638. /**
  639. * Handles a stash exception, giving a useful error to the user.
  640. * @todo Internationalize the exceptions then get rid of this
  641. * @param Exception $e
  642. * @return StatusValue
  643. */
  644. protected function handleStashException( $e ) {
  645. switch ( get_class( $e ) ) {
  646. case 'UploadStashFileNotFoundException':
  647. $wrap = 'apierror-stashedfilenotfound';
  648. break;
  649. case 'UploadStashBadPathException':
  650. $wrap = 'apierror-stashpathinvalid';
  651. break;
  652. case 'UploadStashFileException':
  653. $wrap = 'apierror-stashfilestorage';
  654. break;
  655. case 'UploadStashZeroLengthFileException':
  656. $wrap = 'apierror-stashzerolength';
  657. break;
  658. case 'UploadStashNotLoggedInException':
  659. return StatusValue::newFatal( ApiMessage::create(
  660. [ 'apierror-mustbeloggedin', $this->msg( 'action-upload' ) ], 'stashnotloggedin'
  661. ) );
  662. case 'UploadStashWrongOwnerException':
  663. $wrap = 'apierror-stashwrongowner';
  664. break;
  665. case 'UploadStashNoSuchKeyException':
  666. $wrap = 'apierror-stashnosuchfilekey';
  667. break;
  668. default:
  669. $wrap = [ 'uploadstash-exception', get_class( $e ) ];
  670. break;
  671. }
  672. return StatusValue::newFatal(
  673. $this->getErrorFormatter()->getMessageFromException( $e, [ 'wrap' => $wrap ] )
  674. );
  675. }
  676. /**
  677. * Perform the actual upload. Returns a suitable result array on success;
  678. * dies on failure.
  679. *
  680. * @param array $warnings Array of Api upload warnings
  681. * @return array
  682. */
  683. protected function performUpload( $warnings ) {
  684. // Use comment as initial page text by default
  685. if ( is_null( $this->mParams['text'] ) ) {
  686. $this->mParams['text'] = $this->mParams['comment'];
  687. }
  688. /** @var LocalFile $file */
  689. $file = $this->mUpload->getLocalFile();
  690. // For preferences mode, we want to watch if 'watchdefault' is set,
  691. // or if the *file* doesn't exist, and either 'watchuploads' or
  692. // 'watchcreations' is set. But getWatchlistValue()'s automatic
  693. // handling checks if the *title* exists or not, so we need to check
  694. // all three preferences manually.
  695. $watch = $this->getWatchlistValue(
  696. $this->mParams['watchlist'], $file->getTitle(), 'watchdefault'
  697. );
  698. if ( !$watch && $this->mParams['watchlist'] == 'preferences' && !$file->exists() ) {
  699. $watch = (
  700. $this->getWatchlistValue( 'preferences', $file->getTitle(), 'watchuploads' ) ||
  701. $this->getWatchlistValue( 'preferences', $file->getTitle(), 'watchcreations' )
  702. );
  703. }
  704. // Deprecated parameters
  705. if ( $this->mParams['watch'] ) {
  706. $watch = true;
  707. }
  708. if ( $this->mParams['tags'] ) {
  709. $status = ChangeTags::canAddTagsAccompanyingChange( $this->mParams['tags'], $this->getUser() );
  710. if ( !$status->isOK() ) {
  711. $this->dieStatus( $status );
  712. }
  713. }
  714. // No errors, no warnings: do the upload
  715. if ( $this->mParams['async'] ) {
  716. $progress = UploadBase::getSessionStatus( $this->getUser(), $this->mParams['filekey'] );
  717. if ( $progress && $progress['result'] === 'Poll' ) {
  718. $this->dieWithError( 'apierror-upload-inprogress', 'publishfailed' );
  719. }
  720. UploadBase::setSessionStatus(
  721. $this->getUser(),
  722. $this->mParams['filekey'],
  723. [ 'result' => 'Poll', 'stage' => 'queued', 'status' => Status::newGood() ]
  724. );
  725. JobQueueGroup::singleton()->push( new PublishStashedFileJob(
  726. Title::makeTitle( NS_FILE, $this->mParams['filename'] ),
  727. [
  728. 'filename' => $this->mParams['filename'],
  729. 'filekey' => $this->mParams['filekey'],
  730. 'comment' => $this->mParams['comment'],
  731. 'tags' => $this->mParams['tags'],
  732. 'text' => $this->mParams['text'],
  733. 'watch' => $watch,
  734. 'session' => $this->getContext()->exportSession()
  735. ]
  736. ) );
  737. $result['result'] = 'Poll';
  738. $result['stage'] = 'queued';
  739. } else {
  740. /** @var Status $status */
  741. $status = $this->mUpload->performUpload( $this->mParams['comment'],
  742. $this->mParams['text'], $watch, $this->getUser(), $this->mParams['tags'] );
  743. if ( !$status->isGood() ) {
  744. $this->dieRecoverableError( $status->getErrors() );
  745. }
  746. $result['result'] = 'Success';
  747. }
  748. $result['filename'] = $file->getName();
  749. if ( $warnings && count( $warnings ) > 0 ) {
  750. $result['warnings'] = $warnings;
  751. }
  752. return $result;
  753. }
  754. public function mustBePosted() {
  755. return true;
  756. }
  757. public function isWriteMode() {
  758. return true;
  759. }
  760. public function getAllowedParams() {
  761. $params = [
  762. 'filename' => [
  763. ApiBase::PARAM_TYPE => 'string',
  764. ],
  765. 'comment' => [
  766. ApiBase::PARAM_DFLT => ''
  767. ],
  768. 'tags' => [
  769. ApiBase::PARAM_TYPE => 'tags',
  770. ApiBase::PARAM_ISMULTI => true,
  771. ],
  772. 'text' => [
  773. ApiBase::PARAM_TYPE => 'text',
  774. ],
  775. 'watch' => [
  776. ApiBase::PARAM_DFLT => false,
  777. ApiBase::PARAM_DEPRECATED => true,
  778. ],
  779. 'watchlist' => [
  780. ApiBase::PARAM_DFLT => 'preferences',
  781. ApiBase::PARAM_TYPE => [
  782. 'watch',
  783. 'preferences',
  784. 'nochange'
  785. ],
  786. ],
  787. 'ignorewarnings' => false,
  788. 'file' => [
  789. ApiBase::PARAM_TYPE => 'upload',
  790. ],
  791. 'url' => null,
  792. 'filekey' => null,
  793. 'sessionkey' => [
  794. ApiBase::PARAM_DEPRECATED => true,
  795. ],
  796. 'stash' => false,
  797. 'filesize' => [
  798. ApiBase::PARAM_TYPE => 'integer',
  799. ApiBase::PARAM_MIN => 0,
  800. ApiBase::PARAM_MAX => UploadBase::getMaxUploadSize(),
  801. ],
  802. 'offset' => [
  803. ApiBase::PARAM_TYPE => 'integer',
  804. ApiBase::PARAM_MIN => 0,
  805. ],
  806. 'chunk' => [
  807. ApiBase::PARAM_TYPE => 'upload',
  808. ],
  809. 'async' => false,
  810. 'checkstatus' => false,
  811. ];
  812. return $params;
  813. }
  814. public function needsToken() {
  815. return 'csrf';
  816. }
  817. protected function getExamplesMessages() {
  818. return [
  819. 'action=upload&filename=Wiki.png' .
  820. '&url=http%3A//upload.wikimedia.org/wikipedia/en/b/bc/Wiki.png&token=123ABC'
  821. => 'apihelp-upload-example-url',
  822. 'action=upload&filename=Wiki.png&filekey=filekey&ignorewarnings=1&token=123ABC'
  823. => 'apihelp-upload-example-filekey',
  824. ];
  825. }
  826. public function getHelpUrls() {
  827. return 'https://www.mediawiki.org/wiki/Special:MyLanguage/API:Upload';
  828. }
  829. }