MediaWiki master
ForeignAPIRepo.php
Go to the documentation of this file.
1<?php
7namespace MediaWiki\FileRepo;
8
9use LogicException;
20use RuntimeException;
23use Wikimedia\Timestamp\ConvertibleTimestamp;
24use Wikimedia\Timestamp\TimestampFormat as TS;
25
45 /* This version string is used in the user agent for requests and will help
46 * server maintainers in identify ForeignAPI usage.
47 * Update the version every time you make breaking or significant changes. */
48 private const VERSION = "2.1";
49
51 protected $fileFactory = [ ForeignAPIFile::class, 'newFromTitle' ];
53 protected $apiThumbCacheExpiry = 24 * 3600; // 1 day
54
56 protected $fileCacheExpiry = 30 * 24 * 3600; // 1 month
57
68 protected $apiMetadataExpiry = 4 * 3600; // 4 hours
69
71 protected $mFileExists = [];
72
74 private $mApiBase;
75
77 private $userAgent;
78
82 public function __construct( $info ) {
83 $localFileRepo = MediaWikiServices::getInstance()->getMainConfig()
85 parent::__construct( $info );
86
87 // https://commons.wikimedia.org/w/api.php
88 $this->mApiBase = $info['apibase'] ?? null;
89
90 if ( isset( $info['apiThumbCacheExpiry'] ) ) {
91 $this->apiThumbCacheExpiry = $info['apiThumbCacheExpiry'];
92 }
93 if ( isset( $info['fileCacheExpiry'] ) ) {
94 $this->fileCacheExpiry = $info['fileCacheExpiry'];
95 }
96 if ( isset( $info['apiMetadataExpiry'] ) ) {
97 $this->apiMetadataExpiry = $info['apiMetadataExpiry'];
98 }
99 if ( isset( $info['userAgent'] ) ) {
100 $this->userAgent = $info['userAgent'];
101 }
102 if ( !$this->scriptDirUrl ) {
103 // hack for description fetches
104 $this->scriptDirUrl = dirname( $this->mApiBase );
105 }
106 // If we can cache thumbs we can guess sensible defaults for these
107 if ( $this->canCacheThumbs() && !$this->url ) {
108 $this->url = $localFileRepo['url'];
109 }
110 if ( $this->canCacheThumbs() && !$this->thumbUrl ) {
111 $this->thumbUrl = $this->url . '/thumb';
112 }
113 }
114
123 public function newFile( $title, $time = false ) {
124 if ( $time ) {
125 return false;
126 }
127
128 return parent::newFile( $title, $time );
129 }
130
135 public function fileExistsBatch( array $files ) {
136 $results = [];
137 foreach ( $files as $k => $f ) {
138 if ( isset( $this->mFileExists[$f] ) ) {
139 $results[$k] = $this->mFileExists[$f];
140 unset( $files[$k] );
141 } elseif ( self::isVirtualUrl( $f ) ) {
142 # @todo FIXME: We need to be able to handle virtual
143 # URLs better, at least when we know they refer to the
144 # same repo.
145 $results[$k] = false;
146 unset( $files[$k] );
147 } elseif ( FileBackend::isStoragePath( $f ) ) {
148 $results[$k] = false;
149 unset( $files[$k] );
150 wfWarn( "Got mwstore:// path '$f'." );
151 }
152 }
153
154 $data = $this->fetchImageQuery( [
155 'titles' => implode( '|', $files ),
156 'prop' => 'imageinfo' ]
157 );
158
159 if ( isset( $data['query']['pages'] ) ) {
160 # First, get results from the query. Note we only care whether the image exists,
161 # not whether it has a description page.
162 foreach ( $data['query']['pages'] as $p ) {
163 $this->mFileExists[$p['title']] = ( $p['imagerepository'] !== '' );
164 }
165 # Second, copy the results to any redirects that were queried
166 if ( isset( $data['query']['redirects'] ) ) {
167 foreach ( $data['query']['redirects'] as $r ) {
168 $this->mFileExists[$r['from']] = $this->mFileExists[$r['to']];
169 }
170 }
171 # Third, copy the results to any non-normalized titles that were queried
172 if ( isset( $data['query']['normalized'] ) ) {
173 foreach ( $data['query']['normalized'] as $n ) {
174 $this->mFileExists[$n['from']] = $this->mFileExists[$n['to']];
175 }
176 }
177 # Finally, copy the results to the output
178 foreach ( $files as $key => $file ) {
179 $results[$key] = $this->mFileExists[$file];
180 }
181 }
182
183 return $results;
184 }
185
190 public function getFileProps( $virtualUrl ) {
191 return [];
192 }
193
200 public function fetchImageQuery( $query ) {
201 $query = array_merge( $query, [
202 'format' => 'json',
203 'action' => 'query',
204 'redirects' => 'true',
205 // Optimization: Set uselang to fix CDN caching (T97096)
206 // Optimization: Set uselang=content to improve CDN caching (T56037)
207 // The only localised errors are unreachable via API.
208 'uselang' => 'content',
209 ] );
210
211 $data = $this->httpGetCached( 'Metadata', $query, $this->apiMetadataExpiry );
212
213 if ( $data ) {
214 return FormatJson::decode( $data, true );
215 } else {
216 return null;
217 }
218 }
219
224 public function getImageInfo( $data ) {
225 if ( $data && isset( $data['query']['pages'] ) ) {
226 foreach ( $data['query']['pages'] as $info ) {
227 if ( isset( $info['imageinfo'][0] ) ) {
228 $return = $info['imageinfo'][0];
229 if ( isset( $info['pageid'] ) ) {
230 $return['pageid'] = $info['pageid'];
231 }
232 return $return;
233 }
234 }
235 }
236
237 return false;
238 }
239
244 public function findBySha1( $hash ) {
245 $results = $this->fetchImageQuery( [
246 'list' => 'allimages',
247 'aiprop' => ForeignAPIFile::getProps(),
248 'aisha1base36' => $hash,
249 ] );
250 $ret = [];
251 if ( isset( $results['query']['allimages'] ) ) {
252 foreach ( $results['query']['allimages'] as $img ) {
253 // 1.14 was broken, doesn't return name attribute
254 if ( !isset( $img['name'] ) ) {
255 continue;
256 }
257 $ret[] = new ForeignAPIFile( Title::makeTitle( NS_FILE, $img['name'] ), $this, $img );
258 }
259 }
260
261 return $ret;
262 }
263
280 public function getThumbUrlFromCache( ForeignAPIFile $file, $width, $height, $params = "" ) {
281 // We can't check the local cache using FileRepo functions because
282 // we override fileExistsBatch(). We have to use the FileBackend directly.
283 $backend = $this->getBackend(); // convenience
284
285 if ( !$this->canCacheThumbs() ) {
286 $result = null; // can't pass "null" by reference, but it's ok as default value
287
288 return $file->getThumbUrlFromInfo( $width, $height, $result, $params );
289 }
290
291 // The dbkey form of a file page title
292 $name = $file->getName();
293 $key = $this->getLocalCacheKey( 'file-thumb-url', sha1( $name ) );
294 $sizekey = "$width:$height:$params";
295
296 /* Get the array of urls that we already know */
297 $knownThumbUrls = $this->wanCache->get( $key );
298 if ( !$knownThumbUrls ) {
299 /* No knownThumbUrls for this file */
300 $knownThumbUrls = [];
301 } elseif ( isset( $knownThumbUrls[$sizekey] ) ) {
302 wfDebug( __METHOD__ . ': Got thumburl from local cache: ' .
303 "{$knownThumbUrls[$sizekey]}" );
304
305 return $knownThumbUrls[$sizekey];
306 }
307
308 $metadata = null;
309 $foreignUrl = $file->getThumbUrlFromInfo( $width, $height, $metadata, $params );
310
311 if ( !$foreignUrl || $foreignUrl instanceof MediaTransformError ) {
312 wfDebug( __METHOD__ . " Could not find thumburl" );
313
314 return false;
315 }
316
317 // We need the same filename as the remote one :)
318 $fileName = rawurldecode( pathinfo( $foreignUrl, PATHINFO_BASENAME ) );
319 if ( !$this->validateFilename( $fileName ) ) {
320 wfDebug( __METHOD__ . " The deduced filename $fileName is not safe" );
321
322 return false;
323 }
324 $localPath = $this->getZonePath( 'thumb' ) . "/" . $this->getHashPath( $name ) . $name;
325 $localFilename = $localPath . "/" . $fileName;
326 $localUrl = $this->getZoneUrl( 'thumb' ) . "/" . $this->getHashPath( $name ) .
327 rawurlencode( $name ) . "/" . rawurlencode( $fileName );
328
329 if ( $backend->fileExists( [ 'src' => $localFilename ] )
330 && isset( $metadata['timestamp'] )
331 ) {
332 wfDebug( __METHOD__ . " Thumbnail was already downloaded before" );
333 $modified = (int)wfTimestamp( TS::UNIX, $backend->getFileTimestamp( [ 'src' => $localFilename ] ) );
334 $remoteModified = (int)wfTimestamp( TS::UNIX, $metadata['timestamp'] );
335 $current = (int)ConvertibleTimestamp::now( TS::UNIX );
336 $diff = abs( $modified - $current );
337 if ( $remoteModified < $modified && $diff < $this->fileCacheExpiry ) {
338 /* Use our current and already downloaded thumbnail */
339 $knownThumbUrls[$sizekey] = $localUrl;
340 $this->wanCache->set( $key, $knownThumbUrls, $this->apiThumbCacheExpiry );
341
342 return $localUrl;
343 }
344 /* There is a new Commons file, or existing thumbnail older than a month */
345 }
346
347 $thumb = $this->httpGet( $foreignUrl, 'default', [], $mtime );
348 if ( !$thumb ) {
349 wfDebug( __METHOD__ . " Could not download thumb" );
350
351 return false;
352 }
353
354 # @todo FIXME: Delete old thumbs that aren't being used. Maintenance script?
355 $backend->prepare( [ 'dir' => dirname( $localFilename ) ] );
356 $params = [ 'dst' => $localFilename, 'content' => $thumb ];
357 if ( !$backend->quickCreate( $params )->isOK() ) {
358 wfDebug( __METHOD__ . " could not write to thumb path '$localFilename'" );
359
360 return $foreignUrl;
361 }
362 $knownThumbUrls[$sizekey] = $localUrl;
363 $this->wanCache->set( $key, $knownThumbUrls, $this->apiThumbCacheExpiry );
364 wfDebug( __METHOD__ . " got local thumb $localUrl, saving to cache" );
365
366 return $localUrl;
367 }
368
375 public function getZoneUrl( $zone, $ext = null ) {
376 switch ( $zone ) {
377 case 'public':
378 return $this->url;
379 case 'thumb':
380 return $this->thumbUrl;
381 default:
382 return parent::getZoneUrl( $zone, $ext );
383 }
384 }
385
391 public function getZonePath( $zone ) {
392 $supported = [ 'public', 'thumb' ];
393 if ( in_array( $zone, $supported ) ) {
394 return parent::getZonePath( $zone );
395 }
396
397 return false;
398 }
399
404 public function canCacheThumbs() {
405 return ( $this->apiThumbCacheExpiry > 0 );
406 }
407
412 public function getUserAgent() {
413 $mediaWikiUA = MediaWikiServices::getInstance()->getHttpRequestFactory()->getUserAgent();
414 $classVersion = self::VERSION;
415 $extra = $this->userAgent !== null ? ' ' . $this->userAgent : '';
416 return "$mediaWikiUA ForeignAPIRepo/$classVersion" . $extra;
417 }
418
425 public function getInfo() {
426 $info = parent::getInfo();
427 $info['apiurl'] = $this->mApiBase;
428
429 $query = [
430 'format' => 'json',
431 'action' => 'query',
432 'meta' => 'siteinfo',
433 'siprop' => 'general',
434 ];
435
436 $data = $this->httpGetCached( 'SiteInfo', $query, 7200 );
437
438 if ( $data ) {
439 $siteInfo = FormatJson::decode( $data, true );
440 $general = $siteInfo['query']['general'];
441
442 $info['articlepath'] = $general['articlepath'];
443 $info['server'] = $general['server'];
444 if ( !isset( $info['favicon'] ) && isset( $general['favicon'] ) ) {
445 $info['favicon'] = $general['favicon'];
446 }
447 }
448
449 return $info;
450 }
451
459 public function httpGet(
460 $url, $timeout = 'default', $options = [], &$mtime = false
461 ) {
462 $urlUtils = MediaWikiServices::getInstance()->getUrlUtils();
463 $requestFactory = MediaWikiServices::getInstance()->getHttpRequestFactory();
464
465 $options['timeout'] = $timeout;
466 $url = $urlUtils->expand( $url, PROTO_HTTP );
467 wfDebug( "ForeignAPIRepo: HTTP GET: $url" );
468 if ( !$url ) {
469 return false;
470 }
471 $options['method'] = "GET";
472
473 if ( !isset( $options['timeout'] ) ) {
474 $options['timeout'] = 'default';
475 }
476
477 $options['userAgent'] = $this->getUserAgent();
478
479 $req = $requestFactory->create( $url, $options, __METHOD__ );
480 $req->setHeader( 'Referer', $urlUtils->getCanonicalServer() );
481 $status = $req->execute();
482
483 if ( $status->isOK() ) {
484 $lmod = $req->getResponseHeader( 'Last-Modified' );
485 $mtime = $lmod ? (int)wfTimestamp( TS::UNIX, $lmod ) : false;
486
487 return $req->getContent();
488 } else {
489 $logger = LoggerFactory::getInstance( 'http' );
490 $logger->warning(
491 $status->getWikiText( false, false, 'en' ),
492 [ 'caller' => __METHOD__ ]
493 );
494
495 return false;
496 }
497 }
498
506 public function httpGetCached( $attribute, $query, $cacheTTL = 3600 ) {
507 if ( $this->mApiBase ) {
508 $url = wfAppendQuery( $this->mApiBase, $query );
509 } else {
510 $url = $this->makeUrl( $query, 'api' );
511 }
512
513 return $this->wanCache->getWithSetCallback(
514 // Allow reusing the same cached data across wikis (T285271).
515 // This does not use getSharedCacheKey() because caching here
516 // is transparent to client wikis (which are not expected to issue purges).
517 $this->wanCache->makeGlobalKey( "filerepo-$attribute", sha1( $url ) ),
518 $cacheTTL,
519 function () use ( $url ) {
520 $html = $this->httpGet( $url, 'default', [], $mtime );
521 if ( $html === false ) {
522 $html = null; // caches negatives
523 }
524 return $html;
525 },
526 [ 'pcGroup' => 'http-get:3', 'pcTTL' => WANObjectCache::TTL_PROC_LONG ]
527 );
528 }
529
534 public function enumFiles( $callback ): never {
535 throw new RuntimeException( 'enumFiles is not supported by ' . static::class );
536 }
537
538 protected function assertWritableRepo(): never {
539 throw new LogicException( static::class . ': write operations are not supported.' );
540 }
541}
542
544class_alias( ForeignAPIRepo::class, 'ForeignAPIRepo' );
const NS_FILE
Definition Defines.php:57
const PROTO_HTTP
Definition Defines.php:217
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfWarn( $msg, $callerOffset=1, $level=E_USER_NOTICE)
Send a warning either to the debug log or in a PHP error depending on $wgDevelopmentWarnings.
wfTimestamp( $outputtype=TS::UNIX, $ts=0)
Get a timestamp string in one of various formats.
wfAppendQuery( $url, $query)
Append a query string to an existing URL, which may or may not already have query string parameters a...
makeTitle( $linkId)
Convert a link ID to a Title.to override Title
Base class for file repositories.
Definition FileRepo.php:51
string false $url
Public zone URL.
Definition FileRepo.php:116
getLocalCacheKey( $kClassSuffix,... $components)
Get a site-local, repository-qualified, WAN cache key.
getBackend()
Get the file backend instance.
Definition FileRepo.php:253
string false $thumbUrl
The base thumbnail URL.
Definition FileRepo.php:119
getHashPath( $name)
Get a relative path including trailing slash, e.g.
Definition FileRepo.php:757
makeUrl( $query='', $entry='index')
Make an url to this repo.
Definition FileRepo.php:818
validateFilename( $filename)
Determine if a relative path is valid, i.e.
Implements some public methods and some protected utility functions which are required by multiple ch...
Definition File.php:80
getName()
Return the name of this file.
Definition File.php:348
Foreign file accessible through api.php requests.
getThumbUrlFromInfo( $width, $height, &$result=null, $otherParams='')
static getProps()
Get the properties required for the ForeignAPIFile::__construct $info parameter.
A foreign repository for a remote MediaWiki accessible through api.php requests.
int $apiMetadataExpiry
API metadata cache time.
int $fileCacheExpiry
Redownload thumbnail files after this expiry.
httpGet( $url, $timeout='default', $options=[], &$mtime=false)
assertWritableRepo()
Throw an exception if this repo is read-only by design.
newFile( $title, $time=false)
Per docs in FileRepo, this needs to return false if we don't support versioned files.
fetchImageQuery( $query)
Make an API query in the foreign repo, caching results.
httpGetCached( $attribute, $query, $cacheTTL=3600)
HTTP GET request to a mediawiki API (with caching)
getZonePath( $zone)
Get the local directory corresponding to one of the basic zones.
canCacheThumbs()
Are we locally caching the thumbnails?
getUserAgent()
The user agent the ForeignAPIRepo will use.
getInfo()
Get information about the repo - overrides/extends the parent class's information.
getThumbUrlFromCache(ForeignAPIFile $file, $width, $height, $params="")
Return the imageurl from cache if possible.
int $apiThumbCacheExpiry
Check back with Commons after this expiry.
JSON formatter wrapper class.
Create PSR-3 logger objects.
A class containing constants representing the names of configuration variables.
const LocalFileRepo
Name constant for the LocalFileRepo setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
Basic media transform error class.
Represents a title within MediaWiki.
Definition Title.php:69
Base class for all file backend classes (including multi-write backends).
prepare(array $params)
Prepare a storage directory for usage.
getFileTimestamp(array $params)
Get the last-modified timestamp of the file at a storage path.
fileExists(array $params)
Check if a file exists at a storage path in the backend.
quickCreate(array $params, array $opts=[])
Performs a single quick create operation.
static isStoragePath( $path)
Check if a given path is a "mwstore://" path.
Multi-datacenter aware caching interface.
A foreign repo that implement support for API queries.
Represents the target of a wiki link.
Interface for objects (potentially) representing an editable wiki page.