SpecialUploadStash.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. <?php
  2. /**
  3. * Implements Special:UploadStash.
  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. * Web access for files temporarily stored by UploadStash.
  24. *
  25. * For example -- files that were uploaded with the UploadWizard extension are stored temporarily
  26. * before committing them to the db. But we want to see their thumbnails and get other information
  27. * about them.
  28. *
  29. * Since this is based on the user's session, in effect this creates a private temporary file area.
  30. * However, the URLs for the files cannot be shared.
  31. *
  32. * @ingroup SpecialPage
  33. * @ingroup Upload
  34. */
  35. class SpecialUploadStash extends UnlistedSpecialPage {
  36. // UploadStash
  37. private $stash;
  38. /**
  39. * Since we are directly writing the file to STDOUT,
  40. * we should not be reading in really big files and serving them out.
  41. *
  42. * We also don't want people using this as a file drop, even if they
  43. * share credentials.
  44. *
  45. * This service is really for thumbnails and other such previews while
  46. * uploading.
  47. */
  48. const MAX_SERVE_BYTES = 1048576; // 1MB
  49. public function __construct() {
  50. parent::__construct( 'UploadStash', 'upload' );
  51. }
  52. public function doesWrites() {
  53. return true;
  54. }
  55. /**
  56. * Execute page -- can output a file directly or show a listing of them.
  57. *
  58. * @param string|null $subPage Subpage, e.g. in
  59. * https://example.com/wiki/Special:UploadStash/foo.jpg, the "foo.jpg" part
  60. */
  61. public function execute( $subPage ) {
  62. $this->useTransactionalTimeLimit();
  63. $this->stash = RepoGroup::singleton()->getLocalRepo()->getUploadStash( $this->getUser() );
  64. $this->checkPermissions();
  65. if ( $subPage === null || $subPage === '' ) {
  66. $this->showUploads();
  67. } else {
  68. $this->showUpload( $subPage );
  69. }
  70. }
  71. /**
  72. * If file available in stash, cats it out to the client as a simple HTTP response.
  73. * n.b. Most sanity checking done in UploadStashLocalFile, so this is straightforward.
  74. *
  75. * @param string $key The key of a particular requested file
  76. * @throws HttpError
  77. */
  78. public function showUpload( $key ) {
  79. // prevent callers from doing standard HTML output -- we'll take it from here
  80. $this->getOutput()->disable();
  81. try {
  82. $params = $this->parseKey( $key );
  83. if ( $params['type'] === 'thumb' ) {
  84. $this->outputThumbFromStash( $params['file'], $params['params'] );
  85. } else {
  86. $this->outputLocalFile( $params['file'] );
  87. }
  88. return;
  89. } catch ( UploadStashFileNotFoundException $e ) {
  90. $code = 404;
  91. $message = $e->getMessage();
  92. } catch ( UploadStashZeroLengthFileException $e ) {
  93. $code = 500;
  94. $message = $e->getMessage();
  95. } catch ( UploadStashBadPathException $e ) {
  96. $code = 500;
  97. $message = $e->getMessage();
  98. } catch ( SpecialUploadStashTooLargeException $e ) {
  99. $code = 500;
  100. $message = $e->getMessage();
  101. } catch ( Exception $e ) {
  102. $code = 500;
  103. $message = $e->getMessage();
  104. }
  105. throw new HttpError( $code, $message );
  106. }
  107. /**
  108. * Parse the key passed to the SpecialPage. Returns an array containing
  109. * the associated file object, the type ('file' or 'thumb') and if
  110. * application the transform parameters
  111. *
  112. * @param string $key
  113. * @throws UploadStashBadPathException
  114. * @return array
  115. */
  116. private function parseKey( $key ) {
  117. $type = strtok( $key, '/' );
  118. if ( $type !== 'file' && $type !== 'thumb' ) {
  119. throw new UploadStashBadPathException(
  120. $this->msg( 'uploadstash-bad-path-unknown-type', $type )
  121. );
  122. }
  123. $fileName = strtok( '/' );
  124. $thumbPart = strtok( '/' );
  125. $file = $this->stash->getFile( $fileName );
  126. if ( $type === 'thumb' ) {
  127. $srcNamePos = strrpos( $thumbPart, $fileName );
  128. if ( $srcNamePos === false || $srcNamePos < 1 ) {
  129. throw new UploadStashBadPathException(
  130. $this->msg( 'uploadstash-bad-path-unrecognized-thumb-name' )
  131. );
  132. }
  133. $paramString = substr( $thumbPart, 0, $srcNamePos - 1 );
  134. $handler = $file->getHandler();
  135. if ( $handler ) {
  136. $params = $handler->parseParamString( $paramString );
  137. return [ 'file' => $file, 'type' => $type, 'params' => $params ];
  138. } else {
  139. throw new UploadStashBadPathException(
  140. $this->msg( 'uploadstash-bad-path-no-handler', $file->getMimeType(), $file->getPath() )
  141. );
  142. }
  143. }
  144. return [ 'file' => $file, 'type' => $type ];
  145. }
  146. /**
  147. * Get a thumbnail for file, either generated locally or remotely, and stream it out
  148. *
  149. * @param File $file
  150. * @param array $params
  151. */
  152. private function outputThumbFromStash( $file, $params ) {
  153. $flags = 0;
  154. // this config option, if it exists, points to a "scaler", as you might find in
  155. // the Wikimedia Foundation cluster. See outputRemoteScaledThumb(). This
  156. // is part of our horrible NFS-based system, we create a file on a mount
  157. // point here, but fetch the scaled file from somewhere else that
  158. // happens to share it over NFS.
  159. if ( $this->getConfig()->get( 'UploadStashScalerBaseUrl' ) ) {
  160. $this->outputRemoteScaledThumb( $file, $params, $flags );
  161. } else {
  162. $this->outputLocallyScaledThumb( $file, $params, $flags );
  163. }
  164. }
  165. /**
  166. * Scale a file (probably with a locally installed imagemagick, or similar)
  167. * and output it to STDOUT.
  168. * @param File $file
  169. * @param array $params Scaling parameters ( e.g. [ width => '50' ] );
  170. * @param int $flags Scaling flags ( see File:: constants )
  171. * @throws MWException|UploadStashFileNotFoundException
  172. */
  173. private function outputLocallyScaledThumb( $file, $params, $flags ) {
  174. // n.b. this is stupid, we insist on re-transforming the file every time we are invoked. We rely
  175. // on HTTP caching to ensure this doesn't happen.
  176. $flags |= File::RENDER_NOW;
  177. $thumbnailImage = $file->transform( $params, $flags );
  178. if ( !$thumbnailImage ) {
  179. throw new UploadStashFileNotFoundException(
  180. $this->msg( 'uploadstash-file-not-found-no-thumb' )
  181. );
  182. }
  183. // we should have just generated it locally
  184. if ( !$thumbnailImage->getStoragePath() ) {
  185. throw new UploadStashFileNotFoundException(
  186. $this->msg( 'uploadstash-file-not-found-no-local-path' )
  187. );
  188. }
  189. // now we should construct a File, so we can get MIME and other such info in a standard way
  190. // n.b. MIME type may be different from original (ogx original -> jpeg thumb)
  191. $thumbFile = new UnregisteredLocalFile( false,
  192. $this->stash->repo, $thumbnailImage->getStoragePath(), false );
  193. if ( !$thumbFile ) {
  194. throw new UploadStashFileNotFoundException(
  195. $this->msg( 'uploadstash-file-not-found-no-object' )
  196. );
  197. }
  198. $this->outputLocalFile( $thumbFile );
  199. }
  200. /**
  201. * Scale a file with a remote "scaler", as exists on the Wikimedia Foundation
  202. * cluster, and output it to STDOUT.
  203. * Note: Unlike the usual thumbnail process, the web client never sees the
  204. * cluster URL; we do the whole HTTP transaction to the scaler ourselves
  205. * and cat the results out.
  206. * Note: We rely on NFS to have propagated the file contents to the scaler.
  207. * However, we do not rely on the thumbnail being created in NFS and then
  208. * propagated back to our filesystem. Instead we take the results of the
  209. * HTTP request instead.
  210. * Note: No caching is being done here, although we are instructing the
  211. * client to cache it forever.
  212. *
  213. * @param File $file
  214. * @param array $params Scaling parameters ( e.g. [ width => '50' ] );
  215. * @param int $flags Scaling flags ( see File:: constants )
  216. * @throws MWException
  217. */
  218. private function outputRemoteScaledThumb( $file, $params, $flags ) {
  219. // This option probably looks something like
  220. // '//upload.wikimedia.org/wikipedia/test/thumb/temp'. Do not use
  221. // trailing slash.
  222. $scalerBaseUrl = $this->getConfig()->get( 'UploadStashScalerBaseUrl' );
  223. if ( preg_match( '/^\/\//', $scalerBaseUrl ) ) {
  224. // this is apparently a protocol-relative URL, which makes no sense in this context,
  225. // since this is used for communication that's internal to the application.
  226. // default to http.
  227. $scalerBaseUrl = wfExpandUrl( $scalerBaseUrl, PROTO_CANONICAL );
  228. }
  229. // We need to use generateThumbName() instead of thumbName(), because
  230. // the suffix needs to match the file name for the remote thumbnailer
  231. // to work
  232. $scalerThumbName = $file->generateThumbName( $file->getName(), $params );
  233. $scalerThumbUrl = $scalerBaseUrl . '/' . $file->getUrlRel() .
  234. '/' . rawurlencode( $scalerThumbName );
  235. // If a thumb proxy is set up for the repo, we favor that, as that will
  236. // keep the request internal
  237. $thumbProxyUrl = $file->getRepo()->getThumbProxyUrl();
  238. if ( strlen( $thumbProxyUrl ) ) {
  239. $scalerThumbUrl = $thumbProxyUrl . 'temp/' . $file->getUrlRel() .
  240. '/' . rawurlencode( $scalerThumbName );
  241. }
  242. // make an http request based on wgUploadStashScalerBaseUrl to lazy-create
  243. // a thumbnail
  244. $httpOptions = [
  245. 'method' => 'GET',
  246. 'timeout' => 5 // T90599 attempt to time out cleanly
  247. ];
  248. $req = MWHttpRequest::factory( $scalerThumbUrl, $httpOptions, __METHOD__ );
  249. $secret = $file->getRepo()->getThumbProxySecret();
  250. // Pass a secret key shared with the proxied service if any
  251. if ( strlen( $secret ) ) {
  252. $req->setHeader( 'X-Swift-Secret', $secret );
  253. }
  254. $status = $req->execute();
  255. if ( !$status->isOK() ) {
  256. $errors = $status->getErrorsArray();
  257. throw new UploadStashFileNotFoundException(
  258. $this->msg(
  259. 'uploadstash-file-not-found-no-remote-thumb',
  260. print_r( $errors, 1 ),
  261. $scalerThumbUrl
  262. )
  263. );
  264. }
  265. $contentType = $req->getResponseHeader( "content-type" );
  266. if ( !$contentType ) {
  267. throw new UploadStashFileNotFoundException(
  268. $this->msg( 'uploadstash-file-not-found-missing-content-type' )
  269. );
  270. }
  271. $this->outputContents( $req->getContent(), $contentType );
  272. }
  273. /**
  274. * Output HTTP response for file
  275. * Side effect: writes HTTP response to STDOUT.
  276. *
  277. * @param File $file File object with a local path (e.g. UnregisteredLocalFile,
  278. * LocalFile. Oddly these don't share an ancestor!)
  279. * @throws SpecialUploadStashTooLargeException
  280. */
  281. private function outputLocalFile( File $file ) {
  282. if ( $file->getSize() > self::MAX_SERVE_BYTES ) {
  283. throw new SpecialUploadStashTooLargeException(
  284. $this->msg( 'uploadstash-file-too-large', self::MAX_SERVE_BYTES )
  285. );
  286. }
  287. $file->getRepo()->streamFileWithStatus( $file->getPath(),
  288. [ 'Content-Transfer-Encoding: binary',
  289. 'Expires: Sun, 17-Jan-2038 19:14:07 GMT' ]
  290. );
  291. }
  292. /**
  293. * Output HTTP response of raw content
  294. * Side effect: writes HTTP response to STDOUT.
  295. * @param string $content
  296. * @param string $contentType MIME type
  297. * @throws SpecialUploadStashTooLargeException
  298. */
  299. private function outputContents( $content, $contentType ) {
  300. $size = strlen( $content );
  301. if ( $size > self::MAX_SERVE_BYTES ) {
  302. throw new SpecialUploadStashTooLargeException(
  303. $this->msg( 'uploadstash-file-too-large', self::MAX_SERVE_BYTES )
  304. );
  305. }
  306. // Cancel output buffering and gzipping if set
  307. wfResetOutputBuffers();
  308. self::outputFileHeaders( $contentType, $size );
  309. print $content;
  310. }
  311. /**
  312. * Output headers for streaming
  313. * @todo Unsure about encoding as binary; if we received from HTTP perhaps
  314. * we should use that encoding, concatenated with semicolon to `$contentType` as it
  315. * usually is.
  316. * Side effect: preps PHP to write headers to STDOUT.
  317. * @param string $contentType String suitable for content-type header
  318. * @param string $size Length in bytes
  319. */
  320. private static function outputFileHeaders( $contentType, $size ) {
  321. header( "Content-Type: $contentType", true );
  322. header( 'Content-Transfer-Encoding: binary', true );
  323. header( 'Expires: Sun, 17-Jan-2038 19:14:07 GMT', true );
  324. // T55032 - It shouldn't be a problem here, but let's be safe and not cache
  325. header( 'Cache-Control: private' );
  326. header( "Content-Length: $size", true );
  327. }
  328. /**
  329. * Static callback for the HTMLForm in showUploads, to process
  330. * Note the stash has to be recreated since this is being called in a static context.
  331. * This works, because there really is only one stash per logged-in user, despite appearances.
  332. *
  333. * @param array $formData
  334. * @param HTMLForm $form
  335. * @return Status
  336. */
  337. public static function tryClearStashedUploads( $formData, $form ) {
  338. if ( isset( $formData['Clear'] ) ) {
  339. $stash = RepoGroup::singleton()->getLocalRepo()->getUploadStash( $form->getUser() );
  340. wfDebug( 'stash has: ' . print_r( $stash->listFiles(), true ) . "\n" );
  341. if ( !$stash->clear() ) {
  342. return Status::newFatal( 'uploadstash-errclear' );
  343. }
  344. }
  345. return Status::newGood();
  346. }
  347. /**
  348. * Default action when we don't have a subpage -- just show links to the uploads we have,
  349. * Also show a button to clear stashed files
  350. */
  351. private function showUploads() {
  352. // sets the title, etc.
  353. $this->setHeaders();
  354. $this->outputHeader();
  355. // create the form, which will also be used to execute a callback to process incoming form data
  356. // this design is extremely dubious, but supposedly HTMLForm is our standard now?
  357. $context = new DerivativeContext( $this->getContext() );
  358. $context->setTitle( $this->getPageTitle() ); // Remove subpage
  359. $form = HTMLForm::factory( 'ooui', [
  360. 'Clear' => [
  361. 'type' => 'hidden',
  362. 'default' => true,
  363. 'name' => 'clear',
  364. ]
  365. ], $context, 'clearStashedUploads' );
  366. $form->setSubmitDestructive();
  367. $form->setSubmitCallback( [ __CLASS__, 'tryClearStashedUploads' ] );
  368. $form->setSubmitTextMsg( 'uploadstash-clear' );
  369. $form->prepareForm();
  370. $formResult = $form->tryAuthorizedSubmit();
  371. // show the files + form, if there are any, or just say there are none
  372. $refreshHtml = Html::element( 'a',
  373. [ 'href' => $this->getPageTitle()->getLocalURL() ],
  374. $this->msg( 'uploadstash-refresh' )->text() );
  375. $files = $this->stash->listFiles();
  376. if ( $files && count( $files ) ) {
  377. sort( $files );
  378. $fileListItemsHtml = '';
  379. $linkRenderer = $this->getLinkRenderer();
  380. foreach ( $files as $file ) {
  381. $itemHtml = $linkRenderer->makeKnownLink(
  382. $this->getPageTitle( "file/$file" ),
  383. $file
  384. );
  385. try {
  386. $fileObj = $this->stash->getFile( $file );
  387. $thumb = $fileObj->generateThumbName( $file, [ 'width' => 220 ] );
  388. $itemHtml .=
  389. $this->msg( 'word-separator' )->escaped() .
  390. $this->msg( 'parentheses' )->rawParams(
  391. $linkRenderer->makeKnownLink(
  392. $this->getPageTitle( "thumb/$file/$thumb" ),
  393. $this->msg( 'uploadstash-thumbnail' )->text()
  394. )
  395. )->escaped();
  396. } catch ( Exception $e ) {
  397. }
  398. $fileListItemsHtml .= Html::rawElement( 'li', [], $itemHtml );
  399. }
  400. $this->getOutput()->addHTML( Html::rawElement( 'ul', [], $fileListItemsHtml ) );
  401. $form->displayForm( $formResult );
  402. $this->getOutput()->addHTML( Html::rawElement( 'p', [], $refreshHtml ) );
  403. } else {
  404. $this->getOutput()->addHTML( Html::rawElement( 'p', [],
  405. Html::element( 'span', [], $this->msg( 'uploadstash-nofiles' )->text() )
  406. . ' '
  407. . $refreshHtml
  408. ) );
  409. }
  410. }
  411. }