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