MediaWiki master
LocalRepo.php
Go to the documentation of this file.
1<?php
21namespace MediaWiki\FileRepo;
22
23use Closure;
24use InvalidArgumentException;
38use stdClass;
47
57class LocalRepo extends FileRepo {
59 protected $fileFactory = [ LocalFile::class, 'newFromTitle' ];
61 protected $fileFactoryKey = [ LocalFile::class, 'newFromKey' ];
63 protected $fileFromRowFactory = [ LocalFile::class, 'newFromRow' ];
65 protected $oldFileFromRowFactory = [ OldLocalFile::class, 'newFromRow' ];
67 protected $oldFileFactory = [ OldLocalFile::class, 'newFromTitle' ];
69 protected $oldFileFactoryKey = [ OldLocalFile::class, 'newFromKey' ];
70
72 protected $dbDomain;
76
78 protected $blobStore;
79
81 protected $useJsonMetadata = true;
82
84 protected $useSplitMetadata = false;
85
87 protected $splitMetadataThreshold = 1000;
88
90 protected $updateCompatibleMetadata = false;
91
93 protected $reserializeMetadata = false;
94
95 public function __construct( ?array $info = null ) {
96 parent::__construct( $info );
97
98 $this->dbDomain = WikiMap::getCurrentWikiDbDomain();
99 $this->hasAccessibleSharedCache = true;
100
101 $this->hasSha1Storage = ( $info['storageLayout'] ?? null ) === 'sha1';
102 $this->dbProvider = MediaWikiServices::getInstance()->getConnectionProvider();
103
104 if ( $this->hasSha1Storage() ) {
105 $this->backend = new FileBackendDBRepoWrapper( [
106 'backend' => $this->backend,
107 'repoName' => $this->name,
108 'dbHandleFactory' => $this->getDBFactory()
109 ] );
110 }
111
112 foreach (
113 [
114 'useJsonMetadata',
115 'useSplitMetadata',
116 'splitMetadataThreshold',
117 'updateCompatibleMetadata',
118 'reserializeMetadata',
119 ] as $option
120 ) {
121 if ( isset( $info[$option] ) ) {
122 $this->$option = $info[$option];
123 }
124 }
125 }
126
131 public function newFileFromRow( $row ) {
132 if ( isset( $row->img_name ) ) {
133 return ( $this->fileFromRowFactory )( $row, $this );
134 } elseif ( isset( $row->oi_name ) ) {
135 return ( $this->oldFileFromRowFactory )( $row, $this );
136 } else {
137 throw new InvalidArgumentException( __METHOD__ . ': invalid row' );
138 }
139 }
140
146 public function newFromArchiveName( $title, $archiveName ) {
147 $title = File::normalizeTitle( $title );
148 return OldLocalFile::newFromArchiveName( $title, $this, $archiveName );
149 }
150
161 public function cleanupDeletedBatch( array $storageKeys ) {
162 if ( $this->hasSha1Storage() ) {
163 wfDebug( __METHOD__ . ": skipped because storage uses sha1 paths" );
164 return Status::newGood();
165 }
166
167 $backend = $this->backend; // convenience
168 $root = $this->getZonePath( 'deleted' );
169 $dbw = $this->getPrimaryDB();
170 $status = $this->newGood();
171 $storageKeys = array_unique( $storageKeys );
172 foreach ( $storageKeys as $key ) {
173 $hashPath = $this->getDeletedHashPath( $key );
174 $path = "$root/$hashPath$key";
175 $dbw->startAtomic( __METHOD__ );
176 // Check for usage in deleted/hidden files and preemptively
177 // lock the key to avoid any future use until we are finished.
178 $deleted = $this->deletedFileHasKey( $key, 'lock' );
179 $hidden = $this->hiddenFileHasKey( $key, 'lock' );
180 if ( !$deleted && !$hidden ) { // not in use now
181 wfDebug( __METHOD__ . ": deleting $key" );
182 $op = [ 'op' => 'delete', 'src' => $path ];
183 if ( !$backend->doOperation( $op )->isOK() ) {
184 $status->error( 'undelete-cleanup-error', $path );
185 $status->failCount++;
186 }
187 } else {
188 wfDebug( __METHOD__ . ": $key still in use" );
189 $status->successCount++;
190 }
191 $dbw->endAtomic( __METHOD__ );
192 }
193
194 return $status;
195 }
196
204 protected function deletedFileHasKey( $key, $lock = null ) {
205 $queryBuilder = $this->getPrimaryDB()->newSelectQueryBuilder()
206 ->select( '1' )
207 ->from( 'filearchive' )
208 ->where( [ 'fa_storage_group' => 'deleted', 'fa_storage_key' => $key ] );
209 if ( $lock === 'lock' ) {
210 $queryBuilder->forUpdate();
211 }
212 return (bool)$queryBuilder->caller( __METHOD__ )->fetchField();
213 }
214
222 protected function hiddenFileHasKey( $key, $lock = null ) {
223 $sha1 = self::getHashFromKey( $key );
224 $ext = File::normalizeExtension( substr( $key, strcspn( $key, '.' ) + 1 ) );
225
226 $dbw = $this->getPrimaryDB();
227 $queryBuilder = $dbw->newSelectQueryBuilder()
228 ->select( '1' )
229 ->from( 'oldimage' )
230 ->where( [
231 'oi_sha1' => $sha1,
232 $dbw->expr( 'oi_archive_name', IExpression::LIKE, new LikeValue( $dbw->anyString(), ".$ext" ) ),
233 $dbw->bitAnd( 'oi_deleted', File::DELETED_FILE ) => File::DELETED_FILE,
234 ] );
235 if ( $lock === 'lock' ) {
236 $queryBuilder->forUpdate();
237 }
238
239 return (bool)$queryBuilder->caller( __METHOD__ )->fetchField();
240 }
241
248 public static function getHashFromKey( $key ) {
249 $sha1 = strtok( $key, '.' );
250 if ( is_string( $sha1 ) && strlen( $sha1 ) === 32 && $sha1[0] === '0' ) {
251 $sha1 = substr( $sha1, 1 );
252 }
253 return $sha1;
254 }
255
262 public function checkRedirect( $title ) {
263 $title = File::normalizeTitle( $title, 'exception' );
264
265 $memcKey = $this->getSharedCacheKey( 'file-redirect', md5( $title->getDBkey() ) );
266 if ( $memcKey === false ) {
267 $memcKey = $this->getLocalCacheKey( 'file-redirect', md5( $title->getDBkey() ) );
268 $expiry = 300; // no invalidation, 5 minutes
269 } else {
270 $expiry = 86400; // has invalidation, 1 day
271 }
272
273 $method = __METHOD__;
274 $redirDbKey = $this->wanCache->getWithSetCallback(
275 $memcKey,
276 $expiry,
277 function ( $oldValue, &$ttl, array &$setOpts ) use ( $method, $title ) {
278 $dbr = $this->getReplicaDB(); // possibly remote DB
279
280 $setOpts += Database::getCacheSetOptions( $dbr );
281
282 $row = $dbr->newSelectQueryBuilder()
283 ->select( [ 'rd_namespace', 'rd_title' ] )
284 ->from( 'page' )
285 ->join( 'redirect', null, 'rd_from = page_id' )
286 ->where( [ 'page_namespace' => $title->getNamespace(), 'page_title' => $title->getDBkey() ] )
287 ->caller( $method )->fetchRow();
288
289 return ( $row && $row->rd_namespace == NS_FILE )
290 ? Title::makeTitle( $row->rd_namespace, $row->rd_title )->getDBkey()
291 : ''; // negative cache
292 },
293 [ 'pcTTL' => WANObjectCache::TTL_PROC_LONG ]
294 );
295
296 // @note: also checks " " for b/c
297 if ( $redirDbKey !== ' ' && strval( $redirDbKey ) !== '' ) {
298 // Page is a redirect to another file
299 return Title::newFromText( $redirDbKey, NS_FILE );
300 }
301
302 return false; // no redirect
303 }
304
305 public function findFiles( array $items, $flags = 0 ) {
306 $finalFiles = []; // map of (DB key => corresponding File) for matches
307
308 $searchSet = []; // map of (normalized DB key => search params)
309 foreach ( $items as $item ) {
310 if ( is_array( $item ) ) {
311 $title = File::normalizeTitle( $item['title'] );
312 if ( $title ) {
313 $searchSet[$title->getDBkey()] = $item;
314 }
315 } else {
316 $title = File::normalizeTitle( $item );
317 if ( $title ) {
318 $searchSet[$title->getDBkey()] = [];
319 }
320 }
321 }
322
323 $fileMatchesSearch = static function ( File $file, array $search ) {
324 // Note: file name comparison done elsewhere (to handle redirects)
325
326 // Fallback to RequestContext::getMain should be replaced with a better
327 // way of setting the user that should be used; currently it needs to be
328 // set for each file individually. See T263033#6477586
329 $contextPerformer = RequestContext::getMain()->getAuthority();
330 $performer = ( !empty( $search['private'] ) && $search['private'] instanceof Authority )
331 ? $search['private']
332 : $contextPerformer;
333
334 return (
335 $file->exists() &&
336 (
337 ( empty( $search['time'] ) && !$file->isOld() ) ||
338 ( !empty( $search['time'] ) && $search['time'] === $file->getTimestamp() )
339 ) &&
340 ( !empty( $search['private'] ) || !$file->isDeleted( File::DELETED_FILE ) ) &&
341 $file->userCan( File::DELETED_FILE, $performer )
342 );
343 };
344
345 $applyMatchingFiles = function ( IResultWrapper $res, &$searchSet, &$finalFiles )
346 use ( $fileMatchesSearch, $flags )
347 {
348 $contLang = MediaWikiServices::getInstance()->getContentLanguage();
349 $info = $this->getInfo();
350 foreach ( $res as $row ) {
351 $file = $this->newFileFromRow( $row );
352 // There must have been a search for this DB key, but this has to handle the
353 // cases were title capitalization is different on the client and repo wikis.
354 $dbKeysLook = [ strtr( $file->getName(), ' ', '_' ) ];
355 if ( !empty( $info['initialCapital'] ) ) {
356 // Search keys for "hi.png" and "Hi.png" should use the "Hi.png file"
357 $dbKeysLook[] = $contLang->lcfirst( $file->getName() );
358 }
359 foreach ( $dbKeysLook as $dbKey ) {
360 if ( isset( $searchSet[$dbKey] )
361 && $fileMatchesSearch( $file, $searchSet[$dbKey] )
362 ) {
363 $finalFiles[$dbKey] = ( $flags & FileRepo::NAME_AND_TIME_ONLY )
364 ? [ 'title' => $dbKey, 'timestamp' => $file->getTimestamp() ]
365 : $file;
366 unset( $searchSet[$dbKey] );
367 }
368 }
369 }
370 };
371
372 $dbr = $this->getReplicaDB();
373
374 // Query image table
375 $imgNames = [];
376 foreach ( $searchSet as $dbKey => $_ ) {
377 $imgNames[] = $this->getNameFromTitle( File::normalizeTitle( $dbKey ) );
378 }
379
380 if ( count( $imgNames ) ) {
381 $queryBuilder = FileSelectQueryBuilder::newForFile( $dbr );
382 $res = $queryBuilder->where( [ 'img_name' => $imgNames ] )->caller( __METHOD__ )->fetchResultSet();
383 $applyMatchingFiles( $res, $searchSet, $finalFiles );
384 }
385
386 // Query old image table
387 $oiConds = []; // WHERE clause array for each file
388 foreach ( $searchSet as $dbKey => $search ) {
389 if ( isset( $search['time'] ) ) {
390 $oiConds[] = $dbr
391 ->expr( 'oi_name', '=', $this->getNameFromTitle( File::normalizeTitle( $dbKey ) ) )
392 ->and( 'oi_timestamp', '=', $dbr->timestamp( $search['time'] ) );
393 }
394 }
395
396 if ( count( $oiConds ) ) {
397 $queryBuilder = FileSelectQueryBuilder::newForOldFile( $dbr );
398
399 $res = $queryBuilder->where( $dbr->orExpr( $oiConds ) )
400 ->caller( __METHOD__ )->fetchResultSet();
401 $applyMatchingFiles( $res, $searchSet, $finalFiles );
402 }
403
404 // Check for redirects...
405 foreach ( $searchSet as $dbKey => $search ) {
406 if ( !empty( $search['ignoreRedirect'] ) ) {
407 continue;
408 }
409
410 $title = File::normalizeTitle( $dbKey );
411 $redir = $this->checkRedirect( $title ); // hopefully hits memcached
412
413 if ( $redir && $redir->getNamespace() === NS_FILE ) {
414 $file = $this->newFile( $redir );
415 if ( $file && $fileMatchesSearch( $file, $search ) ) {
416 $file->redirectedFrom( $title->getDBkey() );
417 if ( $flags & FileRepo::NAME_AND_TIME_ONLY ) {
418 $finalFiles[$dbKey] = [
419 'title' => $file->getTitle()->getDBkey(),
420 'timestamp' => $file->getTimestamp()
421 ];
422 } else {
423 $finalFiles[$dbKey] = $file;
424 }
425 }
426 }
427 }
428
429 return $finalFiles;
430 }
431
439 public function findBySha1( $hash ) {
440 $queryBuilder = FileSelectQueryBuilder::newForFile( $this->getReplicaDB() );
441 $res = $queryBuilder->where( [ 'img_sha1' => $hash ] )
442 ->orderBy( 'img_name' )
443 ->caller( __METHOD__ )->fetchResultSet();
444
445 $result = [];
446 foreach ( $res as $row ) {
447 $result[] = $this->newFileFromRow( $row );
448 }
449 $res->free();
450
451 return $result;
452 }
453
463 public function findBySha1s( array $hashes ) {
464 if ( $hashes === [] ) {
465 return []; // empty parameter
466 }
467
468 $dbr = $this->getReplicaDB();
469 $queryBuilder = FileSelectQueryBuilder::newForFile( $dbr );
470
471 $queryBuilder->where( [ 'img_sha1' => $hashes ] )
472 ->orderBy( 'img_name' );
473 $res = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
474
475 $result = [];
476 foreach ( $res as $row ) {
477 $file = $this->newFileFromRow( $row );
478 $result[$file->getSha1()][] = $file;
479 }
480 $res->free();
481
482 return $result;
483 }
484
492 public function findFilesByPrefix( $prefix, $limit ) {
493 $dbr = $this->getReplicaDB();
494 $queryBuilder = FileSelectQueryBuilder::newForFile( $dbr );
495
496 $queryBuilder
497 ->where( $dbr->expr( 'img_name', IExpression::LIKE, new LikeValue( $prefix, $dbr->anyString() ) ) )
498 ->orderBy( 'img_name' )
499 ->limit( intval( $limit ) );
500 $res = $queryBuilder->caller( __METHOD__ )->fetchResultSet();
501
502 // Build file objects
503 $files = [];
504 foreach ( $res as $row ) {
505 $files[] = $this->newFileFromRow( $row );
506 }
507
508 return $files;
509 }
510
515 public function getReplicaDB() {
516 return $this->dbProvider->getReplicaDatabase();
517 }
518
524 public function getPrimaryDB() {
525 return $this->dbProvider->getPrimaryDatabase();
526 }
527
532 protected function getDBFactory() {
533 // TODO: DB_REPLICA/DB_PRIMARY shouldn't be passed around
534 return static function ( $index ) {
535 if ( $index === DB_PRIMARY ) {
536 return MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
537 } else {
538 return MediaWikiServices::getInstance()->getConnectionProvider()->getReplicaDatabase();
539 }
540 };
541 }
542
549 protected function hasAcessibleSharedCache() {
551 }
552
553 public function getSharedCacheKey( $kClassSuffix, ...$components ) {
554 // T267668: do not include the repo name in the key
555 return $this->hasAcessibleSharedCache()
556 ? $this->wanCache->makeGlobalKey(
557 'filerepo-' . $kClassSuffix,
558 $this->dbDomain,
559 ...$components
560 )
561 : false;
562 }
563
570 public function invalidateImageRedirect( $title ) {
571 $key = $this->getSharedCacheKey( 'file-redirect', md5( $title->getDBkey() ) );
572 if ( $key ) {
573 $this->getPrimaryDB()->onTransactionPreCommitOrIdle(
574 function () use ( $key ) {
575 $this->wanCache->delete( $key );
576 },
577 __METHOD__
578 );
579 }
580 }
581
582 public function store( $srcPath, $dstZone, $dstRel, $flags = 0 ) {
583 return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
584 }
585
586 public function storeBatch( array $triplets, $flags = 0 ) {
587 return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
588 }
589
590 public function cleanupBatch( array $files, $flags = 0 ) {
591 return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
592 }
593
594 public function publish(
595 $src,
596 $dstRel,
597 $archiveRel,
598 $flags = 0,
599 array $options = []
600 ) {
601 return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
602 }
603
604 public function publishBatch( array $ntuples, $flags = 0 ) {
605 return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
606 }
607
608 public function delete( $srcRel, $archiveRel ) {
609 return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
610 }
611
612 public function deleteBatch( array $sourceDestPairs ) {
613 return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
614 }
615
623 protected function skipWriteOperationIfSha1( $function, array $args ) {
624 $this->assertWritableRepo(); // fail out if read-only
625
626 if ( $this->hasSha1Storage() ) {
627 wfDebug( __METHOD__ . ": skipped because storage uses sha1 paths" );
628 return Status::newGood();
629 } else {
630 return parent::$function( ...$args );
631 }
632 }
633
643 public function isJsonMetadataEnabled() {
645 }
646
653 public function isSplitMetadataEnabled() {
655 }
656
663 public function getSplitMetadataThreshold() {
665 }
666
667 public function isMetadataUpdateEnabled() {
669 }
670
673 }
674
679 public function getBlobStore(): ?BlobStore {
680 if ( !$this->blobStore ) {
681 $this->blobStore = MediaWikiServices::getInstance()->getBlobStoreFactory()
682 ->newBlobStore( $this->dbDomain );
683 }
684 return $this->blobStore;
685 }
686}
687
689class_alias( LocalRepo::class, 'LocalRepo' );
const NS_FILE
Definition Defines.php:71
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:81
Group all the pieces relevant to the context of a request into one instance.
Proxy backend that manages file layout rewriting for FileRepo.
Base class for file repositories.
Definition FileRepo.php:68
getNameFromTitle( $title)
Get the name of a file from its title.
Definition FileRepo.php:732
getDeletedHashPath( $key)
Get a relative path for a deletion archive key, e.g.
newGood( $value=null)
Create a new good result.
getInfo()
Return information about the repository.
newFile( $title, $time=false)
Create a new File object from the local repository.
Definition FileRepo.php:438
assertWritableRepo()
Throw an exception if this repo is read-only by design.
getLocalCacheKey( $kClassSuffix,... $components)
Get a site-local, repository-qualified, WAN cache key.
getZonePath( $zone)
Get the storage path corresponding to one of the zones.
Definition FileRepo.php:414
hasSha1Storage()
Returns whether or not storage is SHA-1 based.
Implements some public methods and some protected utility functions which are required by multiple ch...
Definition File.php:93
isDeleted( $field)
Is this file a "deleted" file in a private archive? STUB.
Definition File.php:2161
exists()
Returns true if file exists in the repository.
Definition File.php:1049
getTitle()
Return the associated title object.
Definition File.php:391
getTimestamp()
Get the 14-character timestamp of the file upload.
Definition File.php:2395
userCan( $field, Authority $performer)
Determine if the current user is allowed to view a particular field of this file, if it's marked as d...
Definition File.php:2450
isOld()
Returns true if the image is an old version STUB.
Definition File.php:2149
getName()
Return the name of this file.
Definition File.php:361
redirectedFrom(string $from)
Definition File.php:2530
Local file in the wiki's own database.
Definition LocalFile.php:93
Old file in the oldimage table.
Local repository that stores files in the local filesystem and registers them in the wiki's own datab...
Definition LocalRepo.php:57
getSplitMetadataThreshold()
Get the threshold above which metadata items should be split into separate storage,...
findFilesByPrefix( $prefix, $limit)
Return an array of files where the name starts with $prefix.
deleteBatch(array $sourceDestPairs)
Move a group of files to the deletion archive.
cleanupBatch(array $files, $flags=0)
Deletes a batch of files.
invalidateImageRedirect( $title)
Invalidates image redirect cache related to that image.
findFiles(array $items, $flags=0)
Find many files at once.
store( $srcPath, $dstZone, $dstRel, $flags=0)
Store a file to a given destination.
getPrimaryDB()
Get a connection to the primary DB.
cleanupDeletedBatch(array $storageKeys)
Delete files in the deleted directory if they are not referenced in the filearchive table.
checkRedirect( $title)
Checks if there is a redirect named as $title.
hiddenFileHasKey( $key, $lock=null)
Check if a hidden (revision delete) file has this sha1 key.
skipWriteOperationIfSha1( $function, array $args)
Skips the write operation if storage is sha1-based, executes it normally otherwise.
getSharedCacheKey( $kClassSuffix,... $components)
Get a global, repository-qualified, WAN cache key.
getDBFactory()
Get a callback to get a DB handle given an index (DB_REPLICA/DB_PRIMARY)
publishBatch(array $ntuples, $flags=0)
Publish a batch of files.
getBlobStore()
Get a BlobStore for storing and retrieving large metadata, or null if that can't be done.
__construct(?array $info=null)
Definition LocalRepo.php:95
storeBatch(array $triplets, $flags=0)
Store a batch of files.
newFromArchiveName( $title, $archiveName)
deletedFileHasKey( $key, $lock=null)
Check if a deleted (filearchive) file has this sha1 key.
string $dbDomain
DB domain of the repo wiki.
Definition LocalRepo.php:72
IConnectionProvider $dbProvider
Definition LocalRepo.php:73
getReplicaDB()
Get a connection to the replica DB.
findBySha1( $hash)
Get an array or iterator of file objects for files that have a given SHA-1 content hash.
findBySha1s(array $hashes)
Get an array of arrays or iterators of file objects for files that have the given SHA-1 content hashe...
static getHashFromKey( $key)
Gets the SHA1 hash from a storage key.
hasAcessibleSharedCache()
Check whether the repo has a shared cache, accessible from the current site context.
publish( $src, $dstRel, $archiveRel, $flags=0, array $options=[])
Copy or move a file either from a storage path, virtual URL, or file system path, into this repositor...
isSplitMetadataEnabled()
Returns true if files should split up large metadata, storing parts of it in the BlobStore.
bool $hasAccessibleSharedCache
Whether shared cache keys are exposed/accessible.
Definition LocalRepo.php:75
isJsonMetadataEnabled()
Returns true if files should store metadata in JSON format.
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:54
Represents a title within MediaWiki.
Definition Title.php:78
Tools for dealing with other locally-hosted wikis.
Definition WikiMap.php:31
doOperation(array $op, array $opts=[])
Same as doOperations() except it takes a single operation.
Multi-datacenter aware caching interface.
static getCacheSetOptions(?IReadableDatabase ... $dbs)
Merge the result of getSessionLagStatus() for several DBs using the most pessimistic values to estima...
Content of like value.
Definition LikeValue.php:14
Represents the target of a wiki link.
Interface for objects (potentially) representing an editable wiki page.
This interface represents the authority associated with the current execution context,...
Definition Authority.php:37
Service for loading and storing data blobs.
Definition BlobStore.php:33
Provide primary and replica IDatabase connections.
Interface to a relational database.
Definition IDatabase.php:45
A database connection without write operations.
Result wrapper for grabbing data queried from an IDatabase object.
const DB_PRIMARY
Definition defines.php:28