]> scripts.mit.edu Git - autoinstalls/mediawiki.git/blob - includes/filerepo/LocalRepo.php
MediaWiki 1.30.2
[autoinstalls/mediawiki.git] / includes / filerepo / LocalRepo.php
1 <?php
2 /**
3  * Local repository that stores files in the local filesystem and registers them
4  * in the wiki's own database.
5  *
6  * This program is free software; you can redistribute it and/or modify
7  * it under the terms of the GNU General Public License as published by
8  * the Free Software Foundation; either version 2 of the License, or
9  * (at your option) any later version.
10  *
11  * This program is distributed in the hope that it will be useful,
12  * but WITHOUT ANY WARRANTY; without even the implied warranty of
13  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14  * GNU General Public License for more details.
15  *
16  * You should have received a copy of the GNU General Public License along
17  * with this program; if not, write to the Free Software Foundation, Inc.,
18  * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19  * http://www.gnu.org/copyleft/gpl.html
20  *
21  * @file
22  * @ingroup FileRepo
23  */
24
25 use Wikimedia\Rdbms\ResultWrapper;
26 use Wikimedia\Rdbms\Database;
27 use Wikimedia\Rdbms\IDatabase;
28
29 /**
30  * A repository that stores files in the local filesystem and registers them
31  * in the wiki's own database. This is the most commonly used repository class.
32  *
33  * @ingroup FileRepo
34  */
35 class LocalRepo extends FileRepo {
36         /** @var callable */
37         protected $fileFactory = [ 'LocalFile', 'newFromTitle' ];
38         /** @var callable */
39         protected $fileFactoryKey = [ 'LocalFile', 'newFromKey' ];
40         /** @var callable */
41         protected $fileFromRowFactory = [ 'LocalFile', 'newFromRow' ];
42         /** @var callable */
43         protected $oldFileFromRowFactory = [ 'OldLocalFile', 'newFromRow' ];
44         /** @var callable */
45         protected $oldFileFactory = [ 'OldLocalFile', 'newFromTitle' ];
46         /** @var callable */
47         protected $oldFileFactoryKey = [ 'OldLocalFile', 'newFromKey' ];
48
49         function __construct( array $info = null ) {
50                 parent::__construct( $info );
51
52                 $this->hasSha1Storage = isset( $info['storageLayout'] )
53                         && $info['storageLayout'] === 'sha1';
54
55                 if ( $this->hasSha1Storage() ) {
56                         $this->backend = new FileBackendDBRepoWrapper( [
57                                 'backend'         => $this->backend,
58                                 'repoName'        => $this->name,
59                                 'dbHandleFactory' => $this->getDBFactory()
60                         ] );
61                 }
62         }
63
64         /**
65          * @throws MWException
66          * @param stdClass $row
67          * @return LocalFile
68          */
69         function newFileFromRow( $row ) {
70                 if ( isset( $row->img_name ) ) {
71                         return call_user_func( $this->fileFromRowFactory, $row, $this );
72                 } elseif ( isset( $row->oi_name ) ) {
73                         return call_user_func( $this->oldFileFromRowFactory, $row, $this );
74                 } else {
75                         throw new MWException( __METHOD__ . ': invalid row' );
76                 }
77         }
78
79         /**
80          * @param Title $title
81          * @param string $archiveName
82          * @return OldLocalFile
83          */
84         function newFromArchiveName( $title, $archiveName ) {
85                 return OldLocalFile::newFromArchiveName( $title, $this, $archiveName );
86         }
87
88         /**
89          * Delete files in the deleted directory if they are not referenced in the
90          * filearchive table. This needs to be done in the repo because it needs to
91          * interleave database locks with file operations, which is potentially a
92          * remote operation.
93          *
94          * @param array $storageKeys
95          *
96          * @return Status
97          */
98         function cleanupDeletedBatch( array $storageKeys ) {
99                 if ( $this->hasSha1Storage() ) {
100                         wfDebug( __METHOD__ . ": skipped because storage uses sha1 paths\n" );
101                         return Status::newGood();
102                 }
103
104                 $backend = $this->backend; // convenience
105                 $root = $this->getZonePath( 'deleted' );
106                 $dbw = $this->getMasterDB();
107                 $status = $this->newGood();
108                 $storageKeys = array_unique( $storageKeys );
109                 foreach ( $storageKeys as $key ) {
110                         $hashPath = $this->getDeletedHashPath( $key );
111                         $path = "$root/$hashPath$key";
112                         $dbw->startAtomic( __METHOD__ );
113                         // Check for usage in deleted/hidden files and preemptively
114                         // lock the key to avoid any future use until we are finished.
115                         $deleted = $this->deletedFileHasKey( $key, 'lock' );
116                         $hidden = $this->hiddenFileHasKey( $key, 'lock' );
117                         if ( !$deleted && !$hidden ) { // not in use now
118                                 wfDebug( __METHOD__ . ": deleting $key\n" );
119                                 $op = [ 'op' => 'delete', 'src' => $path ];
120                                 if ( !$backend->doOperation( $op )->isOK() ) {
121                                         $status->error( 'undelete-cleanup-error', $path );
122                                         $status->failCount++;
123                                 }
124                         } else {
125                                 wfDebug( __METHOD__ . ": $key still in use\n" );
126                                 $status->successCount++;
127                         }
128                         $dbw->endAtomic( __METHOD__ );
129                 }
130
131                 return $status;
132         }
133
134         /**
135          * Check if a deleted (filearchive) file has this sha1 key
136          *
137          * @param string $key File storage key (base-36 sha1 key with file extension)
138          * @param string|null $lock Use "lock" to lock the row via FOR UPDATE
139          * @return bool File with this key is in use
140          */
141         protected function deletedFileHasKey( $key, $lock = null ) {
142                 $options = ( $lock === 'lock' ) ? [ 'FOR UPDATE' ] : [];
143
144                 $dbw = $this->getMasterDB();
145
146                 return (bool)$dbw->selectField( 'filearchive', '1',
147                         [ 'fa_storage_group' => 'deleted', 'fa_storage_key' => $key ],
148                         __METHOD__, $options
149                 );
150         }
151
152         /**
153          * Check if a hidden (revision delete) file has this sha1 key
154          *
155          * @param string $key File storage key (base-36 sha1 key with file extension)
156          * @param string|null $lock Use "lock" to lock the row via FOR UPDATE
157          * @return bool File with this key is in use
158          */
159         protected function hiddenFileHasKey( $key, $lock = null ) {
160                 $options = ( $lock === 'lock' ) ? [ 'FOR UPDATE' ] : [];
161
162                 $sha1 = self::getHashFromKey( $key );
163                 $ext = File::normalizeExtension( substr( $key, strcspn( $key, '.' ) + 1 ) );
164
165                 $dbw = $this->getMasterDB();
166
167                 return (bool)$dbw->selectField( 'oldimage', '1',
168                         [ 'oi_sha1' => $sha1,
169                                 'oi_archive_name ' . $dbw->buildLike( $dbw->anyString(), ".$ext" ),
170                                 $dbw->bitAnd( 'oi_deleted', File::DELETED_FILE ) => File::DELETED_FILE ],
171                         __METHOD__, $options
172                 );
173         }
174
175         /**
176          * Gets the SHA1 hash from a storage key
177          *
178          * @param string $key
179          * @return string
180          */
181         public static function getHashFromKey( $key ) {
182                 return strtok( $key, '.' );
183         }
184
185         /**
186          * Checks if there is a redirect named as $title
187          *
188          * @param Title $title Title of file
189          * @return bool|Title
190          */
191         function checkRedirect( Title $title ) {
192                 $title = File::normalizeTitle( $title, 'exception' );
193
194                 $memcKey = $this->getSharedCacheKey( 'image_redirect', md5( $title->getDBkey() ) );
195                 if ( $memcKey === false ) {
196                         $memcKey = $this->getLocalCacheKey( 'image_redirect', md5( $title->getDBkey() ) );
197                         $expiry = 300; // no invalidation, 5 minutes
198                 } else {
199                         $expiry = 86400; // has invalidation, 1 day
200                 }
201
202                 $method = __METHOD__;
203                 $redirDbKey = ObjectCache::getMainWANInstance()->getWithSetCallback(
204                         $memcKey,
205                         $expiry,
206                         function ( $oldValue, &$ttl, array &$setOpts ) use ( $method, $title ) {
207                                 $dbr = $this->getReplicaDB(); // possibly remote DB
208
209                                 $setOpts += Database::getCacheSetOptions( $dbr );
210
211                                 if ( $title instanceof Title ) {
212                                         $row = $dbr->selectRow(
213                                                 [ 'page', 'redirect' ],
214                                                 [ 'rd_namespace', 'rd_title' ],
215                                                 [
216                                                         'page_namespace' => $title->getNamespace(),
217                                                         'page_title' => $title->getDBkey(),
218                                                         'rd_from = page_id'
219                                                 ],
220                                                 $method
221                                         );
222                                 } else {
223                                         $row = false;
224                                 }
225
226                                 return ( $row && $row->rd_namespace == NS_FILE )
227                                         ? Title::makeTitle( $row->rd_namespace, $row->rd_title )->getDBkey()
228                                         : ''; // negative cache
229                         },
230                         [ 'pcTTL' => WANObjectCache::TTL_PROC_LONG ]
231                 );
232
233                 // @note: also checks " " for b/c
234                 if ( $redirDbKey !== ' ' && strval( $redirDbKey ) !== '' ) {
235                         // Page is a redirect to another file
236                         return Title::newFromText( $redirDbKey, NS_FILE );
237                 }
238
239                 return false; // no redirect
240         }
241
242         public function findFiles( array $items, $flags = 0 ) {
243                 $finalFiles = []; // map of (DB key => corresponding File) for matches
244
245                 $searchSet = []; // map of (normalized DB key => search params)
246                 foreach ( $items as $item ) {
247                         if ( is_array( $item ) ) {
248                                 $title = File::normalizeTitle( $item['title'] );
249                                 if ( $title ) {
250                                         $searchSet[$title->getDBkey()] = $item;
251                                 }
252                         } else {
253                                 $title = File::normalizeTitle( $item );
254                                 if ( $title ) {
255                                         $searchSet[$title->getDBkey()] = [];
256                                 }
257                         }
258                 }
259
260                 $fileMatchesSearch = function ( File $file, array $search ) {
261                         // Note: file name comparison done elsewhere (to handle redirects)
262                         $user = ( !empty( $search['private'] ) && $search['private'] instanceof User )
263                                 ? $search['private']
264                                 : null;
265
266                         return (
267                                 $file->exists() &&
268                                 (
269                                         ( empty( $search['time'] ) && !$file->isOld() ) ||
270                                         ( !empty( $search['time'] ) && $search['time'] === $file->getTimestamp() )
271                                 ) &&
272                                 ( !empty( $search['private'] ) || !$file->isDeleted( File::DELETED_FILE ) ) &&
273                                 $file->userCan( File::DELETED_FILE, $user )
274                         );
275                 };
276
277                 $applyMatchingFiles = function ( ResultWrapper $res, &$searchSet, &$finalFiles )
278                         use ( $fileMatchesSearch, $flags )
279                 {
280                         global $wgContLang;
281                         $info = $this->getInfo();
282                         foreach ( $res as $row ) {
283                                 $file = $this->newFileFromRow( $row );
284                                 // There must have been a search for this DB key, but this has to handle the
285                                 // cases were title capitalization is different on the client and repo wikis.
286                                 $dbKeysLook = [ strtr( $file->getName(), ' ', '_' ) ];
287                                 if ( !empty( $info['initialCapital'] ) ) {
288                                         // Search keys for "hi.png" and "Hi.png" should use the "Hi.png file"
289                                         $dbKeysLook[] = $wgContLang->lcfirst( $file->getName() );
290                                 }
291                                 foreach ( $dbKeysLook as $dbKey ) {
292                                         if ( isset( $searchSet[$dbKey] )
293                                                 && $fileMatchesSearch( $file, $searchSet[$dbKey] )
294                                         ) {
295                                                 $finalFiles[$dbKey] = ( $flags & FileRepo::NAME_AND_TIME_ONLY )
296                                                         ? [ 'title' => $dbKey, 'timestamp' => $file->getTimestamp() ]
297                                                         : $file;
298                                                 unset( $searchSet[$dbKey] );
299                                         }
300                                 }
301                         }
302                 };
303
304                 $dbr = $this->getReplicaDB();
305
306                 // Query image table
307                 $imgNames = [];
308                 foreach ( array_keys( $searchSet ) as $dbKey ) {
309                         $imgNames[] = $this->getNameFromTitle( File::normalizeTitle( $dbKey ) );
310                 }
311
312                 if ( count( $imgNames ) ) {
313                         $res = $dbr->select( 'image',
314                                 LocalFile::selectFields(), [ 'img_name' => $imgNames ], __METHOD__ );
315                         $applyMatchingFiles( $res, $searchSet, $finalFiles );
316                 }
317
318                 // Query old image table
319                 $oiConds = []; // WHERE clause array for each file
320                 foreach ( $searchSet as $dbKey => $search ) {
321                         if ( isset( $search['time'] ) ) {
322                                 $oiConds[] = $dbr->makeList(
323                                         [
324                                                 'oi_name' => $this->getNameFromTitle( File::normalizeTitle( $dbKey ) ),
325                                                 'oi_timestamp' => $dbr->timestamp( $search['time'] )
326                                         ],
327                                         LIST_AND
328                                 );
329                         }
330                 }
331
332                 if ( count( $oiConds ) ) {
333                         $res = $dbr->select( 'oldimage',
334                                 OldLocalFile::selectFields(), $dbr->makeList( $oiConds, LIST_OR ), __METHOD__ );
335                         $applyMatchingFiles( $res, $searchSet, $finalFiles );
336                 }
337
338                 // Check for redirects...
339                 foreach ( $searchSet as $dbKey => $search ) {
340                         if ( !empty( $search['ignoreRedirect'] ) ) {
341                                 continue;
342                         }
343
344                         $title = File::normalizeTitle( $dbKey );
345                         $redir = $this->checkRedirect( $title ); // hopefully hits memcached
346
347                         if ( $redir && $redir->getNamespace() == NS_FILE ) {
348                                 $file = $this->newFile( $redir );
349                                 if ( $file && $fileMatchesSearch( $file, $search ) ) {
350                                         $file->redirectedFrom( $title->getDBkey() );
351                                         if ( $flags & FileRepo::NAME_AND_TIME_ONLY ) {
352                                                 $finalFiles[$dbKey] = [
353                                                         'title' => $file->getTitle()->getDBkey(),
354                                                         'timestamp' => $file->getTimestamp()
355                                                 ];
356                                         } else {
357                                                 $finalFiles[$dbKey] = $file;
358                                         }
359                                 }
360                         }
361                 }
362
363                 return $finalFiles;
364         }
365
366         /**
367          * Get an array or iterator of file objects for files that have a given
368          * SHA-1 content hash.
369          *
370          * @param string $hash A sha1 hash to look for
371          * @return File[]
372          */
373         function findBySha1( $hash ) {
374                 $dbr = $this->getReplicaDB();
375                 $res = $dbr->select(
376                         'image',
377                         LocalFile::selectFields(),
378                         [ 'img_sha1' => $hash ],
379                         __METHOD__,
380                         [ 'ORDER BY' => 'img_name' ]
381                 );
382
383                 $result = [];
384                 foreach ( $res as $row ) {
385                         $result[] = $this->newFileFromRow( $row );
386                 }
387                 $res->free();
388
389                 return $result;
390         }
391
392         /**
393          * Get an array of arrays or iterators of file objects for files that
394          * have the given SHA-1 content hashes.
395          *
396          * Overrides generic implementation in FileRepo for performance reason
397          *
398          * @param array $hashes An array of hashes
399          * @return array An Array of arrays or iterators of file objects and the hash as key
400          */
401         function findBySha1s( array $hashes ) {
402                 if ( !count( $hashes ) ) {
403                         return []; // empty parameter
404                 }
405
406                 $dbr = $this->getReplicaDB();
407                 $res = $dbr->select(
408                         'image',
409                         LocalFile::selectFields(),
410                         [ 'img_sha1' => $hashes ],
411                         __METHOD__,
412                         [ 'ORDER BY' => 'img_name' ]
413                 );
414
415                 $result = [];
416                 foreach ( $res as $row ) {
417                         $file = $this->newFileFromRow( $row );
418                         $result[$file->getSha1()][] = $file;
419                 }
420                 $res->free();
421
422                 return $result;
423         }
424
425         /**
426          * Return an array of files where the name starts with $prefix.
427          *
428          * @param string $prefix The prefix to search for
429          * @param int $limit The maximum amount of files to return
430          * @return array
431          */
432         public function findFilesByPrefix( $prefix, $limit ) {
433                 $selectOptions = [ 'ORDER BY' => 'img_name', 'LIMIT' => intval( $limit ) ];
434
435                 // Query database
436                 $dbr = $this->getReplicaDB();
437                 $res = $dbr->select(
438                         'image',
439                         LocalFile::selectFields(),
440                         'img_name ' . $dbr->buildLike( $prefix, $dbr->anyString() ),
441                         __METHOD__,
442                         $selectOptions
443                 );
444
445                 // Build file objects
446                 $files = [];
447                 foreach ( $res as $row ) {
448                         $files[] = $this->newFileFromRow( $row );
449                 }
450
451                 return $files;
452         }
453
454         /**
455          * Get a connection to the replica DB
456          * @return IDatabase
457          */
458         function getReplicaDB() {
459                 return wfGetDB( DB_REPLICA );
460         }
461
462         /**
463          * Alias for getReplicaDB()
464          *
465          * @return IDatabase
466          * @deprecated Since 1.29
467          */
468         function getSlaveDB() {
469                 return $this->getReplicaDB();
470         }
471
472         /**
473          * Get a connection to the master DB
474          * @return IDatabase
475          */
476         function getMasterDB() {
477                 return wfGetDB( DB_MASTER );
478         }
479
480         /**
481          * Get a callback to get a DB handle given an index (DB_REPLICA/DB_MASTER)
482          * @return Closure
483          */
484         protected function getDBFactory() {
485                 return function ( $index ) {
486                         return wfGetDB( $index );
487                 };
488         }
489
490         /**
491          * Get a key on the primary cache for this repository.
492          * Returns false if the repository's cache is not accessible at this site.
493          * The parameters are the parts of the key, as for wfMemcKey().
494          *
495          * @return string
496          */
497         function getSharedCacheKey( /*...*/ ) {
498                 $args = func_get_args();
499
500                 return call_user_func_array( 'wfMemcKey', $args );
501         }
502
503         /**
504          * Invalidates image redirect cache related to that image
505          *
506          * @param Title $title Title of page
507          * @return void
508          */
509         function invalidateImageRedirect( Title $title ) {
510                 $key = $this->getSharedCacheKey( 'image_redirect', md5( $title->getDBkey() ) );
511                 if ( $key ) {
512                         $this->getMasterDB()->onTransactionPreCommitOrIdle(
513                                 function () use ( $key ) {
514                                         ObjectCache::getMainWANInstance()->delete( $key );
515                                 },
516                                 __METHOD__
517                         );
518                 }
519         }
520
521         /**
522          * Return information about the repository.
523          *
524          * @return array
525          * @since 1.22
526          */
527         function getInfo() {
528                 global $wgFavicon;
529
530                 return array_merge( parent::getInfo(), [
531                         'favicon' => wfExpandUrl( $wgFavicon ),
532                 ] );
533         }
534
535         public function store( $srcPath, $dstZone, $dstRel, $flags = 0 ) {
536                 return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
537         }
538
539         public function storeBatch( array $triplets, $flags = 0 ) {
540                 return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
541         }
542
543         public function cleanupBatch( array $files, $flags = 0 ) {
544                 return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
545         }
546
547         public function publish(
548                 $src,
549                 $dstRel,
550                 $archiveRel,
551                 $flags = 0,
552                 array $options = []
553         ) {
554                 return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
555         }
556
557         public function publishBatch( array $ntuples, $flags = 0 ) {
558                 return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
559         }
560
561         public function delete( $srcRel, $archiveRel ) {
562                 return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
563         }
564
565         public function deleteBatch( array $sourceDestPairs ) {
566                 return $this->skipWriteOperationIfSha1( __FUNCTION__, func_get_args() );
567         }
568
569         /**
570          * Skips the write operation if storage is sha1-based, executes it normally otherwise
571          *
572          * @param string $function
573          * @param array $args
574          * @return Status
575          */
576         protected function skipWriteOperationIfSha1( $function, array $args ) {
577                 $this->assertWritableRepo(); // fail out if read-only
578
579                 if ( $this->hasSha1Storage() ) {
580                         wfDebug( __METHOD__ . ": skipped because storage uses sha1 paths\n" );
581                         return Status::newGood();
582                 } else {
583                         return call_user_func_array( 'parent::' . $function, $args );
584                 }
585         }
586 }