Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
82.41% |
267 / 324 |
|
44.44% |
4 / 9 |
CRAP | |
0.00% |
0 / 1 |
ThumbnailEntryPoint | |
82.41% |
267 / 324 |
|
44.44% |
4 / 9 |
132.13 | |
0.00% |
0 / 1 |
execute | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
doPrepareForOutput | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
handleRequest | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
streamThumb | |
82.09% |
165 / 201 |
|
0.00% |
0 / 1 |
70.75 | |||
proxyThumbnailRequest | |
78.57% |
11 / 14 |
|
0.00% |
0 / 1 |
4.16 | |||
generateThumbnail | |
78.26% |
36 / 46 |
|
0.00% |
0 / 1 |
11.03 | |||
extractThumbParams | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
8 | |||
thumbErrorText | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
thumbError | |
79.41% |
27 / 34 |
|
0.00% |
0 / 1 |
9.71 |
1 | <?php |
2 | /** |
3 | * Entry point implementation for retrieving media thumbnails, created by a MediaHandler |
4 | * subclass or proxy request if FileRepo::getThumbProxyUrl is configured. |
5 | * |
6 | * This also supports resizing an image on-demand, if it isn't found in the |
7 | * configured FileBackend storage. |
8 | * |
9 | * @see /thumb.php The web entry point. |
10 | * |
11 | * This program is free software; you can redistribute it and/or modify |
12 | * it under the terms of the GNU General Public License as published by |
13 | * the Free Software Foundation; either version 2 of the License, or |
14 | * (at your option) any later version. |
15 | * |
16 | * This program is distributed in the hope that it will be useful, |
17 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
18 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
19 | * GNU General Public License for more details. |
20 | * |
21 | * You should have received a copy of the GNU General Public License along |
22 | * with this program; if not, write to the Free Software Foundation, Inc., |
23 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
24 | * http://www.gnu.org/copyleft/gpl.html |
25 | * |
26 | * @file |
27 | * @ingroup entrypoint |
28 | * @ingroup Media |
29 | */ |
30 | |
31 | namespace MediaWiki\FileRepo; |
32 | |
33 | use Exception; |
34 | use File; |
35 | use InvalidArgumentException; |
36 | use MediaTransformInvalidParametersException; |
37 | use MediaTransformOutput; |
38 | use MediaWiki\Logger\LoggerFactory; |
39 | use MediaWiki\MainConfigNames; |
40 | use MediaWiki\MediaWikiEntryPoint; |
41 | use MediaWiki\MediaWikiServices; |
42 | use MediaWiki\Permissions\PermissionStatus; |
43 | use MediaWiki\PoolCounter\PoolCounterWorkViaCallback; |
44 | use MediaWiki\Profiler\ProfilingContext; |
45 | use MediaWiki\Request\HeaderCallback; |
46 | use MediaWiki\Status\Status; |
47 | use MediaWiki\Title\Title; |
48 | use MessageSpecifier; |
49 | use ObjectCache; |
50 | use UnregisteredLocalFile; |
51 | use Wikimedia\AtEase\AtEase; |
52 | |
53 | class ThumbnailEntryPoint extends MediaWikiEntryPoint { |
54 | |
55 | /** |
56 | * Main entry point |
57 | */ |
58 | public function execute() { |
59 | global $wgTrivialMimeDetection; |
60 | |
61 | ProfilingContext::singleton()->init( |
62 | MW_ENTRY_POINT, |
63 | 'stream' |
64 | ); |
65 | |
66 | // Don't use fancy MIME detection, just check the file extension for jpg/gif/png. |
67 | // NOTE: This only works as long as to StreamFile::contentTypeFromPath |
68 | // get this setting from global state. When StreamFile gets refactored, |
69 | // we need to find a better way. |
70 | $wgTrivialMimeDetection = true; |
71 | |
72 | $this->handleRequest(); |
73 | } |
74 | |
75 | protected function doPrepareForOutput() { |
76 | // No-op. |
77 | // Do not call parent::doPrepareForOutput() to avoid |
78 | // commitMainTransaction() getting called. |
79 | } |
80 | |
81 | protected function handleRequest() { |
82 | $this->streamThumb( $this->getRequest()->getQueryValuesOnly() ); |
83 | } |
84 | |
85 | /** |
86 | * Stream a thumbnail specified by parameters |
87 | * |
88 | * @param array $params List of thumbnailing parameters. In addition to parameters |
89 | * passed to the MediaHandler, this may also includes the keys: |
90 | * f (for filename), archived (if archived file), temp (if temp file), |
91 | * w (alias for width), p (alias for page), r (ignored; historical), |
92 | * rel404 (path for render on 404 to verify hash path correct), |
93 | * thumbName (thumbnail name to potentially extract more parameters from |
94 | * e.g. 'lossy-page1-120px-Foo.tiff' would add page, lossy and width |
95 | * to the parameters) |
96 | * @return void |
97 | */ |
98 | protected function streamThumb( array $params ) { |
99 | $varyOnXFP = $this->getConfig( MainConfigNames::VaryOnXFP ); |
100 | |
101 | $headers = []; // HTTP headers to send |
102 | |
103 | $fileName = $params['f'] ?? ''; |
104 | |
105 | // Backwards compatibility parameters |
106 | if ( isset( $params['w'] ) ) { |
107 | $params['width'] = $params['w']; |
108 | unset( $params['w'] ); |
109 | } |
110 | if ( isset( $params['width'] ) && substr( $params['width'], -2 ) == 'px' ) { |
111 | // strip the px (pixel) suffix, if found |
112 | $params['width'] = substr( $params['width'], 0, -2 ); |
113 | } |
114 | if ( isset( $params['p'] ) ) { |
115 | $params['page'] = $params['p']; |
116 | } |
117 | |
118 | // Is this a thumb of an archived file? |
119 | $isOld = ( isset( $params['archived'] ) && $params['archived'] ); |
120 | unset( $params['archived'] ); // handlers don't care |
121 | |
122 | // Is this a thumb of a temp file? |
123 | $isTemp = ( isset( $params['temp'] ) && $params['temp'] ); |
124 | unset( $params['temp'] ); // handlers don't care |
125 | |
126 | $services = MediaWikiServices::getInstance(); |
127 | |
128 | // Some basic input validation |
129 | $fileName = strtr( $fileName, '\\/', '__' ); |
130 | $localRepo = $services->getRepoGroup()->getLocalRepo(); |
131 | $archiveTimestamp = null; |
132 | |
133 | // Actually fetch the image. Method depends on whether it is archived or not. |
134 | if ( $isTemp ) { |
135 | $repo = $localRepo->getTempRepo(); |
136 | $img = new UnregisteredLocalFile( false, $repo, |
137 | # Temp files are hashed based on the name without the timestamp. |
138 | # The thumbnails will be hashed based on the entire name however. |
139 | # @todo fix this convention to actually be reasonable. |
140 | $repo->getZonePath( 'public' ) . '/' . $repo->getTempHashPath( $fileName ) . $fileName |
141 | ); |
142 | } elseif ( $isOld ) { |
143 | // Format is <timestamp>!<name> |
144 | $bits = explode( '!', $fileName, 2 ); |
145 | if ( count( $bits ) != 2 ) { |
146 | $this->thumbError( 404, $this->getContext()->msg( 'badtitletext' )->parse() ); |
147 | return; |
148 | } |
149 | $archiveTimestamp = $bits[0]; |
150 | $title = Title::makeTitleSafe( NS_FILE, $bits[1] ); |
151 | if ( !$title ) { |
152 | $this->thumbError( 404, $this->getContext()->msg( 'badtitletext' )->parse() ); |
153 | return; |
154 | } |
155 | $img = $localRepo->newFromArchiveName( $title, $fileName ); |
156 | } else { |
157 | $img = $localRepo->newFile( $fileName ); |
158 | } |
159 | |
160 | // Check the source file title |
161 | if ( !$img ) { |
162 | $this->thumbError( 404, $this->getContext()->msg( 'badtitletext' )->parse() ); |
163 | return; |
164 | } |
165 | |
166 | // Check permissions if there are read restrictions |
167 | $varyHeader = []; |
168 | if ( !$services->getGroupPermissionsLookup()->groupHasPermission( '*', 'read' ) ) { |
169 | $authority = $this->getContext()->getAuthority(); |
170 | $imgTitle = $img->getTitle(); |
171 | |
172 | if ( !$imgTitle || !$authority->authorizeRead( 'read', $imgTitle ) ) { |
173 | $this->thumbErrorText( 403, 'Access denied. You do not have permission to access ' . |
174 | 'the source file.' ); |
175 | return; |
176 | } |
177 | $headers[] = 'Cache-Control: private'; |
178 | $varyHeader[] = 'Cookie'; |
179 | } |
180 | |
181 | // Check if the file is hidden |
182 | if ( $img->isDeleted( File::DELETED_FILE ) ) { |
183 | $this->thumbErrorText( 404, "The source file '$fileName' does not exist." ); |
184 | return; |
185 | } |
186 | |
187 | // Do rendering parameters extraction from thumbnail name. |
188 | if ( isset( $params['thumbName'] ) ) { |
189 | $params = $this->extractThumbParams( $img, $params ); |
190 | } |
191 | if ( $params == null ) { |
192 | $this->thumbErrorText( 400, 'The specified thumbnail parameters are not recognized.' ); |
193 | return; |
194 | } |
195 | |
196 | // Check the source file storage path |
197 | if ( !$img->exists() ) { |
198 | $redirectedLocation = false; |
199 | if ( !$isTemp ) { |
200 | // Check for file redirect |
201 | // Since redirects are associated with pages, not versions of files, |
202 | // we look for the most current version to see if its a redirect. |
203 | $possRedirFile = $localRepo->findFile( $img->getName() ); |
204 | if ( $possRedirFile && $possRedirFile->getRedirected() !== null ) { |
205 | $redirTarget = $possRedirFile->getName(); |
206 | $targetFile = $localRepo->newFile( Title::makeTitleSafe( NS_FILE, $redirTarget ) ); |
207 | if ( $targetFile->exists() ) { |
208 | $newThumbName = $targetFile->thumbName( $params ); |
209 | if ( $isOld ) { |
210 | $newThumbUrl = $targetFile->getArchiveThumbUrl( |
211 | $archiveTimestamp . '!' . $targetFile->getName(), $newThumbName ); |
212 | } else { |
213 | $newThumbUrl = $targetFile->getThumbUrl( $newThumbName ); |
214 | } |
215 | $redirectedLocation = wfExpandUrl( $newThumbUrl, PROTO_CURRENT ); |
216 | } |
217 | } |
218 | } |
219 | |
220 | if ( $redirectedLocation ) { |
221 | // File has been moved. Give redirect. |
222 | $response = $this->getResponse(); |
223 | $response->statusHeader( 302 ); |
224 | $response->header( 'Location: ' . $redirectedLocation ); |
225 | $response->header( 'Expires: ' . |
226 | gmdate( 'D, d M Y H:i:s', time() + 12 * 3600 ) . ' GMT' ); |
227 | if ( $varyOnXFP ) { |
228 | $varyHeader[] = 'X-Forwarded-Proto'; |
229 | } |
230 | if ( count( $varyHeader ) ) { |
231 | $response->header( 'Vary: ' . implode( ', ', $varyHeader ) ); |
232 | } |
233 | $response->header( 'Content-Length: 0' ); |
234 | return; |
235 | } |
236 | |
237 | // If it's not a redirect that has a target as a local file, give 404. |
238 | $this->thumbErrorText( 404, "The source file '$fileName' does not exist." ); |
239 | return; |
240 | } elseif ( $img->getPath() === false ) { |
241 | $this->thumbErrorText( 400, "The source file '$fileName' is not locally accessible." ); |
242 | return; |
243 | } |
244 | |
245 | // Check IMS against the source file |
246 | // This means that clients can keep a cached copy even after it has been deleted on the server |
247 | if ( $this->getServerInfo( 'HTTP_IF_MODIFIED_SINCE', '' ) !== '' ) { |
248 | // Fix IE brokenness |
249 | $imsString = preg_replace( |
250 | '/;.*$/', |
251 | '', |
252 | $this->getServerInfo( 'HTTP_IF_MODIFIED_SINCE' ) ?? '' |
253 | ); |
254 | // Calculate time |
255 | AtEase::suppressWarnings(); |
256 | $imsUnix = strtotime( $imsString ); |
257 | AtEase::restoreWarnings(); |
258 | if ( wfTimestamp( TS_UNIX, $img->getTimestamp() ) <= $imsUnix ) { |
259 | $this->status( 304 ); |
260 | return; |
261 | } |
262 | } |
263 | |
264 | $rel404 = $params['rel404'] ?? null; |
265 | unset( $params['r'] ); // ignore 'r' because we unconditionally pass File::RENDER |
266 | unset( $params['f'] ); // We're done with 'f' parameter. |
267 | unset( $params['rel404'] ); // moved to $rel404 |
268 | |
269 | // Get the normalized thumbnail name from the parameters... |
270 | try { |
271 | $thumbName = $img->thumbName( $params ); |
272 | if ( !strlen( $thumbName ?? '' ) ) { // invalid params? |
273 | throw new MediaTransformInvalidParametersException( |
274 | 'Empty return from File::thumbName' |
275 | ); |
276 | } |
277 | $thumbName2 = $img->thumbName( $params, File::THUMB_FULL_NAME ); // b/c; "long" style |
278 | } catch ( MediaTransformInvalidParametersException $e ) { |
279 | $this->thumbErrorText( |
280 | 400, |
281 | 'The specified thumbnail parameters are not valid: ' . $e->getMessage() |
282 | ); |
283 | return; |
284 | } |
285 | |
286 | // For 404 handled thumbnails, we only use the base name of the URI |
287 | // for the thumb params and the parent directory for the source file name. |
288 | // Check that the zone relative path matches up so CDN caches won't pick |
289 | // up thumbs that would not be purged on source file deletion (T36231). |
290 | if ( $rel404 !== null ) { // thumbnail was handled via 404 |
291 | if ( rawurldecode( $rel404 ) === $img->getThumbRel( $thumbName ) ) { |
292 | // Request for the canonical thumbnail name |
293 | } elseif ( rawurldecode( $rel404 ) === $img->getThumbRel( $thumbName2 ) ) { |
294 | // Request for the "long" thumbnail name; redirect to canonical name |
295 | $this->status( 301 ); |
296 | $this->header( 'Location: ' . |
297 | wfExpandUrl( $img->getThumbUrl( $thumbName ), PROTO_CURRENT ) ); |
298 | $this->header( 'Expires: ' . |
299 | gmdate( 'D, d M Y H:i:s', time() + 7 * 86400 ) . ' GMT' ); |
300 | if ( $varyOnXFP ) { |
301 | $varyHeader[] = 'X-Forwarded-Proto'; |
302 | } |
303 | if ( count( $varyHeader ) ) { |
304 | $this->header( 'Vary: ' . implode( ', ', $varyHeader ) ); |
305 | } |
306 | return; |
307 | } else { |
308 | $this->thumbErrorText( 404, "The given path of the specified thumbnail is incorrect; |
309 | expected '" . $img->getThumbRel( $thumbName ) . "' but got '" . |
310 | rawurldecode( $rel404 ) . "'." ); |
311 | return; |
312 | } |
313 | } |
314 | |
315 | $dispositionType = isset( $params['download'] ) ? 'attachment' : 'inline'; |
316 | |
317 | // Suggest a good name for users downloading this thumbnail |
318 | $headers[] = |
319 | 'Content-Disposition: ' . $img->getThumbDisposition( $thumbName, $dispositionType ); |
320 | |
321 | if ( count( $varyHeader ) ) { |
322 | $headers[] = 'Vary: ' . implode( ', ', $varyHeader ); |
323 | } |
324 | |
325 | // Stream the file if it exists already... |
326 | $thumbPath = $img->getThumbPath( $thumbName ); |
327 | if ( $img->getRepo()->fileExists( $thumbPath ) ) { |
328 | $starttime = microtime( true ); |
329 | $status = $img->getRepo()->streamFileWithStatus( $thumbPath, $headers ); |
330 | $streamtime = microtime( true ) - $starttime; |
331 | |
332 | if ( $status->isOK() ) { |
333 | $services->getStatsdDataFactory()->timing( |
334 | 'media.thumbnail.stream', |
335 | $streamtime |
336 | ); |
337 | } else { |
338 | $this->thumbError( |
339 | 500, |
340 | 'Could not stream the file', |
341 | $status->getWikiText( false, false, 'en' ), |
342 | [ |
343 | 'file' => $thumbName, |
344 | 'path' => $thumbPath, |
345 | 'error' => $status->getWikiText( false, false, 'en' ), |
346 | ] |
347 | ); |
348 | } |
349 | return; |
350 | } |
351 | |
352 | $authority = $this->getContext()->getAuthority(); |
353 | $status = PermissionStatus::newEmpty(); |
354 | if ( !wfThumbIsStandard( $img, $params ) |
355 | && !$authority->authorizeAction( 'renderfile-nonstandard', $status ) |
356 | ) { |
357 | $statusFormatter = $services->getFormatterFactory() |
358 | ->getStatusFormatter( $this->getContext() ); |
359 | |
360 | $this->thumbError( 429, $statusFormatter->getHTML( $status ) ); |
361 | return; |
362 | } elseif ( !$authority->authorizeAction( 'renderfile', $status ) ) { |
363 | $statusFormatter = $services->getFormatterFactory() |
364 | ->getStatusFormatter( $this->getContext() ); |
365 | |
366 | $this->thumbError( 429, $statusFormatter->getHTML( $status ) ); |
367 | return; |
368 | } |
369 | |
370 | $thumbProxyUrl = $img->getRepo()->getThumbProxyUrl(); |
371 | |
372 | if ( strlen( $thumbProxyUrl ?? '' ) ) { |
373 | $this->proxyThumbnailRequest( $img, $thumbName ); |
374 | // No local fallback when in proxy mode |
375 | return; |
376 | } else { |
377 | // Generate the thumbnail locally |
378 | [ $thumb, $errorMsg ] = $this->generateThumbnail( $img, $params, $thumbName, $thumbPath ); |
379 | } |
380 | |
381 | /** @var MediaTransformOutput|false $thumb */ |
382 | |
383 | // Check for thumbnail generation errors... |
384 | $msg = $this->getContext()->msg( 'thumbnail_error' ); |
385 | $errorCode = 500; |
386 | |
387 | if ( !$thumb ) { |
388 | $errorMsg = $errorMsg ?: $msg->rawParams( 'File::transform() returned false' )->escaped(); |
389 | if ( $errorMsg instanceof MessageSpecifier && |
390 | $errorMsg->getKey() === 'thumbnail_image-failure-limit' |
391 | ) { |
392 | $errorCode = 429; |
393 | } |
394 | } elseif ( $thumb->isError() ) { |
395 | $errorMsg = $thumb->getHtmlMsg(); |
396 | $errorCode = $thumb->getHttpStatusCode(); |
397 | } elseif ( !$thumb->hasFile() ) { |
398 | $errorMsg = $msg->rawParams( 'No path supplied in thumbnail object' )->escaped(); |
399 | } elseif ( $thumb->fileIsSource() ) { |
400 | $errorMsg = $msg |
401 | ->rawParams( 'Image was not scaled, is the requested width bigger than the source?' ) |
402 | ->escaped(); |
403 | $errorCode = 400; |
404 | } |
405 | |
406 | $this->prepareForOutput(); |
407 | |
408 | if ( $errorMsg !== false ) { |
409 | $this->thumbError( $errorCode, $errorMsg, null, [ 'file' => $thumbName, 'path' => $thumbPath ] ); |
410 | } else { |
411 | // Stream the file if there were no errors |
412 | '@phan-var MediaTransformOutput $thumb'; |
413 | $status = $thumb->streamFileWithStatus( $headers ); |
414 | if ( !$status->isOK() ) { |
415 | $this->thumbError( 500, 'Could not stream the file', $status->getWikiText( false, false, 'en' ), [ |
416 | 'file' => $thumbName, 'path' => $thumbPath, |
417 | 'error' => $status->getWikiText( false, false, 'en' ) ] ); |
418 | } |
419 | } |
420 | } |
421 | |
422 | /** |
423 | * Proxies thumbnail request to a service that handles thumbnailing |
424 | * |
425 | * @param File $img |
426 | * @param string $thumbName |
427 | */ |
428 | private function proxyThumbnailRequest( $img, $thumbName ) { |
429 | $thumbProxyUrl = $img->getRepo()->getThumbProxyUrl(); |
430 | |
431 | // Instead of generating the thumbnail ourselves, we proxy the request to another service |
432 | $thumbProxiedUrl = $thumbProxyUrl . $img->getThumbRel( $thumbName ); |
433 | |
434 | $req = MediaWikiServices::getInstance()->getHttpRequestFactory()->create( $thumbProxiedUrl ); |
435 | $secret = $img->getRepo()->getThumbProxySecret(); |
436 | |
437 | // Pass a secret key shared with the proxied service if any |
438 | if ( strlen( $secret ?? '' ) ) { |
439 | $req->setHeader( 'X-Swift-Secret', $secret ); |
440 | } |
441 | |
442 | // Send request to proxied service |
443 | $req->execute(); |
444 | |
445 | HeaderCallback::warnIfHeadersSent(); |
446 | |
447 | // Simply serve the response from the proxied service as-is |
448 | $this->header( 'HTTP/1.1 ' . $req->getStatus() ); |
449 | |
450 | $headers = $req->getResponseHeaders(); |
451 | |
452 | foreach ( $headers as $key => $values ) { |
453 | foreach ( $values as $value ) { |
454 | $this->header( $key . ': ' . $value, false ); |
455 | } |
456 | } |
457 | |
458 | $this->print( $req->getContent() ); |
459 | } |
460 | |
461 | /** |
462 | * Actually try to generate a new thumbnail |
463 | * |
464 | * @param File $file |
465 | * @param array $params |
466 | * @param string $thumbName |
467 | * @param string $thumbPath |
468 | * @return array (MediaTransformOutput|bool, string|bool error message HTML) |
469 | */ |
470 | protected function generateThumbnail( File $file, array $params, $thumbName, $thumbPath ) { |
471 | $attemptFailureEpoch = $this->getConfig( MainConfigNames::AttemptFailureEpoch ); |
472 | |
473 | $cache = ObjectCache::getLocalClusterInstance(); |
474 | $key = $cache->makeKey( |
475 | 'attempt-failures', |
476 | $attemptFailureEpoch, |
477 | $file->getRepo()->getName(), |
478 | $file->getSha1(), |
479 | md5( $thumbName ) |
480 | ); |
481 | |
482 | // Check if this file keeps failing to render |
483 | if ( $cache->get( $key ) >= 4 ) { |
484 | return [ false, $this->getContext()->msg( 'thumbnail_image-failure-limit', 4 ) ]; |
485 | } |
486 | |
487 | $done = false; |
488 | // Record failures on PHP fatals in addition to caching exceptions |
489 | register_shutdown_function( static function () use ( $cache, &$done, $key ) { |
490 | if ( !$done ) { // transform() gave a fatal |
491 | // Randomize TTL to reduce stampedes |
492 | $cache->incrWithInit( $key, $cache::TTL_HOUR + mt_rand( 0, 300 ) ); |
493 | } |
494 | } ); |
495 | |
496 | $thumb = false; |
497 | $errorHtml = false; |
498 | |
499 | // guard thumbnail rendering with PoolCounter to avoid stampedes |
500 | // expensive files use a separate PoolCounter config so it is possible |
501 | // to set up a global limit on them |
502 | if ( $file->isExpensiveToThumbnail() ) { |
503 | $poolCounterType = 'FileRenderExpensive'; |
504 | } else { |
505 | $poolCounterType = 'FileRender'; |
506 | } |
507 | |
508 | // Thumbnail isn't already there, so create the new thumbnail... |
509 | try { |
510 | $work = new PoolCounterWorkViaCallback( $poolCounterType, sha1( $file->getName() ), |
511 | [ |
512 | 'doWork' => static function () use ( $file, $params ) { |
513 | return $file->transform( $params, File::RENDER_NOW ); |
514 | }, |
515 | 'doCachedWork' => static function () use ( $file, $params, $thumbPath ) { |
516 | // If the worker that finished made this thumbnail then use it. |
517 | // Otherwise, it probably made a different thumbnail for this file. |
518 | return $file->getRepo()->fileExists( $thumbPath ) |
519 | ? $file->transform( $params, File::RENDER_NOW ) |
520 | : false; // retry once more in exclusive mode |
521 | }, |
522 | 'error' => function ( Status $status ) { |
523 | return $this->getContext()->msg( 'generic-pool-error' )->parse() . '<hr>' . $status->getHTML(); |
524 | } |
525 | ] |
526 | ); |
527 | $result = $work->execute(); |
528 | if ( $result instanceof MediaTransformOutput ) { |
529 | $thumb = $result; |
530 | } elseif ( is_string( $result ) ) { // error |
531 | $errorHtml = $result; |
532 | } |
533 | } catch ( Exception $e ) { |
534 | // Tried to select a page on a non-paged file? |
535 | } |
536 | |
537 | /** @noinspection PhpUnusedLocalVariableInspection */ |
538 | $done = true; // no PHP fatal occurred |
539 | |
540 | if ( !$thumb || $thumb->isError() ) { |
541 | // Randomize TTL to reduce stampedes |
542 | $cache->incrWithInit( $key, $cache::TTL_HOUR + mt_rand( 0, 300 ) ); |
543 | } |
544 | |
545 | return [ $thumb, $errorHtml ]; |
546 | } |
547 | |
548 | /** |
549 | * Convert a thumbnail name (122px-foo.png) to parameters, using |
550 | * file handler. |
551 | * |
552 | * @param File $file File object for file in question |
553 | * @param array $params Array of parameters so far |
554 | * @return array|null Parameters array with more parameters, or null |
555 | */ |
556 | private function extractThumbParams( $file, $params ) { |
557 | if ( !isset( $params['thumbName'] ) ) { |
558 | throw new InvalidArgumentException( "No thumbnail name passed to extractThumbParams" ); |
559 | } |
560 | |
561 | $thumbname = $params['thumbName']; |
562 | unset( $params['thumbName'] ); |
563 | |
564 | // FIXME: Files in the temp zone don't set a MIME type, which means |
565 | // they don't have a handler. Which means we can't parse the param |
566 | // string. However, not a big issue as what good is a param string |
567 | // if you have no handler to make use of the param string and |
568 | // actually generate the thumbnail. |
569 | $handler = $file->getHandler(); |
570 | |
571 | // Based on UploadStash::parseKey |
572 | $fileNamePos = strrpos( $thumbname, $params['f'] ); |
573 | if ( $fileNamePos === false ) { |
574 | // Maybe using a short filename? (see FileRepo::nameForThumb) |
575 | $fileNamePos = strrpos( $thumbname, 'thumbnail' ); |
576 | } |
577 | |
578 | if ( $handler && $fileNamePos !== false ) { |
579 | $paramString = substr( $thumbname, 0, $fileNamePos - 1 ); |
580 | $extraParams = $handler->parseParamString( $paramString ); |
581 | if ( $extraParams !== false ) { |
582 | return $params + $extraParams; |
583 | } |
584 | } |
585 | |
586 | // As a last ditch fallback, use the traditional common parameters |
587 | if ( preg_match( '!^(page(\d*)-)*(\d*)px-[^/]*$!', $thumbname, $matches ) ) { |
588 | [ /* all */, /* pagefull */, $pagenum, $size ] = $matches; |
589 | $params['width'] = $size; |
590 | if ( $pagenum ) { |
591 | $params['page'] = $pagenum; |
592 | } |
593 | return $params; // valid thumbnail URL |
594 | } |
595 | return null; |
596 | } |
597 | |
598 | /** |
599 | * Output a thumbnail generation error message |
600 | * |
601 | * @param int $status |
602 | * @param string $msgText Plain text (will be html escaped) |
603 | * @return void |
604 | */ |
605 | protected function thumbErrorText( $status, $msgText ) { |
606 | $this->thumbError( $status, htmlspecialchars( $msgText, ENT_NOQUOTES ) ); |
607 | } |
608 | |
609 | /** |
610 | * Output a thumbnail generation error message |
611 | * |
612 | * @param int $status |
613 | * @param string $msgHtml HTML |
614 | * @param string|null $msgText Short error description, for internal logging. Defaults to $msgHtml. |
615 | * Only used for HTTP 500 errors. |
616 | * @param array $context Error context, for internal logging. Only used for HTTP 500 errors. |
617 | * @return void |
618 | */ |
619 | protected function thumbError( $status, $msgHtml, $msgText = null, $context = [] ) { |
620 | $showHostnames = $this->getConfig( MainConfigNames::ShowHostnames ); |
621 | |
622 | HeaderCallback::warnIfHeadersSent(); |
623 | |
624 | if ( $this->getResponse()->headersSent() ) { |
625 | LoggerFactory::getInstance( 'thumbnail' )->error( |
626 | 'Error after output had been started. Output may be corrupt or truncated. ' . |
627 | 'Original error: ' . ( $msgText ?: $msgHtml ) . " (Status $status)", |
628 | $context |
629 | ); |
630 | return; |
631 | } |
632 | |
633 | $this->header( 'Cache-Control: no-cache' ); |
634 | $this->header( 'Content-Type: text/html; charset=utf-8' ); |
635 | if ( $status == 400 || $status == 404 || $status == 429 ) { |
636 | $this->status( $status ); |
637 | } elseif ( $status == 403 ) { |
638 | $this->status( 403 ); |
639 | $this->header( 'Vary: Cookie' ); |
640 | } else { |
641 | LoggerFactory::getInstance( 'thumbnail' )->error( $msgText ?: $msgHtml, $context ); |
642 | $this->status( 500 ); |
643 | } |
644 | if ( $showHostnames ) { |
645 | $this->header( 'X-MW-Thumbnail-Renderer: ' . wfHostname() ); |
646 | $url = htmlspecialchars( |
647 | $this->getServerInfo( 'REQUEST_URI' ) ?? '', |
648 | ENT_NOQUOTES |
649 | ); |
650 | $hostname = htmlspecialchars( wfHostname(), ENT_NOQUOTES ); |
651 | $debug = "<!-- $url -->\n<!-- $hostname -->\n"; |
652 | } else { |
653 | $debug = ''; |
654 | } |
655 | $content = <<<EOT |
656 | <!DOCTYPE html> |
657 | <html><head> |
658 | <meta charset="UTF-8" /> |
659 | <title>Error generating thumbnail</title> |
660 | </head> |
661 | <body> |
662 | <h1>Error generating thumbnail</h1> |
663 | <p> |
664 | $msgHtml |
665 | </p> |
666 | $debug |
667 | </body> |
668 | </html> |
669 | |
670 | EOT; |
671 | $this->header( 'Content-Length: ' . strlen( $content ) ); |
672 | $this->print( $content ); |
673 | } |
674 | |
675 | } |