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