1<?php 2/** 3 * File backend registration handling. 4 * 5 * This program is free software; you can redistribute it and/or modify 6 * it under the terms of the GNU General Public License as published by 7 * the Free Software Foundation; either version 2 of the License, or 8 * (at your option) any later version. 9 * 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * 15 * You should have received a copy of the GNU General Public License along 16 * with this program; if not, write to the Free Software Foundation, Inc., 17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 18 * http://www.gnu.org/copyleft/gpl.html 19 * 20 * @file 21 * @ingroup FileBackend 22 */ 23 24use MediaWiki\Config\ServiceOptions; 25use MediaWiki\FileBackend\FSFile\TempFSFileFactory; 26use MediaWiki\FileBackend\LockManager\LockManagerGroupFactory; 27use MediaWiki\Logger\LoggerFactory; 28use MediaWiki\MediaWikiServices; 29use Wikimedia\ObjectFactory; 30 31/** 32 * Class to handle file backend registration 33 * 34 * @ingroup FileBackend 35 * @since 1.19 36 */ 37class FileBackendGroup { 38 /** 39 * @var array[] (name => ('class' => string, 'config' => array, 'instance' => object)) 40 * @phan-var array<string,array{class:class-string,config:array,instance:object}> 41 */ 42 protected $backends = []; 43 44 /** @var ServiceOptions */ 45 private $options; 46 47 /** @var BagOStuff */ 48 private $srvCache; 49 50 /** @var WANObjectCache */ 51 private $wanCache; 52 53 /** @var MimeAnalyzer */ 54 private $mimeAnalyzer; 55 56 /** @var LockManagerGroupFactory */ 57 private $lmgFactory; 58 59 /** @var TempFSFileFactory */ 60 private $tmpFileFactory; 61 62 /** @var ObjectFactory */ 63 private $objectFactory; 64 65 /** 66 * @internal 67 */ 68 public const CONSTRUCTOR_OPTIONS = [ 69 'DirectoryMode', 70 'FileBackends', 71 'ForeignFileRepos', 72 'LocalFileRepo', 73 'fallbackWikiId', 74 ]; 75 76 /** 77 * @deprecated since 1.35, inject the service instead 78 * @return FileBackendGroup 79 */ 80 public static function singleton() : FileBackendGroup { 81 return MediaWikiServices::getInstance()->getFileBackendGroup(); 82 } 83 84 /** 85 * Destroy the singleton instance 86 * 87 * @deprecated since 1.35, test framework should reset services between tests instead 88 */ 89 public static function destroySingleton() { 90 MediaWikiServices::getInstance()->resetServiceForTesting( 'FileBackendGroup' ); 91 } 92 93 /** 94 * @param ServiceOptions $options 95 * @param ConfiguredReadOnlyMode $configuredReadOnlyMode 96 * @param BagOStuff $srvCache 97 * @param WANObjectCache $wanCache 98 * @param MimeAnalyzer $mimeAnalyzer 99 * @param LockManagerGroupFactory $lmgFactory 100 * @param TempFSFileFactory $tmpFileFactory 101 * @param ObjectFactory $objectFactory 102 */ 103 public function __construct( 104 ServiceOptions $options, 105 ConfiguredReadOnlyMode $configuredReadOnlyMode, 106 BagOStuff $srvCache, 107 WANObjectCache $wanCache, 108 MimeAnalyzer $mimeAnalyzer, 109 LockManagerGroupFactory $lmgFactory, 110 TempFSFileFactory $tmpFileFactory, 111 ObjectFactory $objectFactory 112 ) { 113 $this->options = $options; 114 $this->srvCache = $srvCache; 115 $this->wanCache = $wanCache; 116 $this->mimeAnalyzer = $mimeAnalyzer; 117 $this->lmgFactory = $lmgFactory; 118 $this->tmpFileFactory = $tmpFileFactory; 119 $this->objectFactory = $objectFactory; 120 121 // Register explicitly defined backends 122 $this->register( $options->get( 'FileBackends' ), $configuredReadOnlyMode->getReason() ); 123 124 $autoBackends = []; 125 // Automatically create b/c backends for file repos... 126 $repos = array_merge( 127 $options->get( 'ForeignFileRepos' ), [ $options->get( 'LocalFileRepo' ) ] ); 128 foreach ( $repos as $info ) { 129 $backendName = $info['backend']; 130 if ( is_object( $backendName ) || isset( $this->backends[$backendName] ) ) { 131 continue; // already defined (or set to the object for some reason) 132 } 133 $repoName = $info['name']; 134 // Local vars that used to be FSRepo members... 135 $directory = $info['directory']; 136 $deletedDir = $info['deletedDir'] ?? false; // deletion disabled 137 $thumbDir = $info['thumbDir'] ?? "{$directory}/thumb"; 138 $transcodedDir = $info['transcodedDir'] ?? "{$directory}/transcoded"; 139 // Get the FS backend configuration 140 $autoBackends[] = [ 141 'name' => $backendName, 142 'class' => FSFileBackend::class, 143 'lockManager' => 'fsLockManager', 144 'containerPaths' => [ 145 "{$repoName}-public" => "{$directory}", 146 "{$repoName}-thumb" => $thumbDir, 147 "{$repoName}-transcoded" => $transcodedDir, 148 "{$repoName}-deleted" => $deletedDir, 149 "{$repoName}-temp" => "{$directory}/temp" 150 ], 151 'fileMode' => $info['fileMode'] ?? 0644, 152 'directoryMode' => $options->get( 'DirectoryMode' ), 153 ]; 154 } 155 156 // Register implicitly defined backends 157 $this->register( $autoBackends, $configuredReadOnlyMode->getReason() ); 158 } 159 160 /** 161 * Register an array of file backend configurations 162 * 163 * @param array[] $configs 164 * @param string|null $readOnlyReason 165 * @throws InvalidArgumentException 166 */ 167 protected function register( array $configs, $readOnlyReason = null ) { 168 foreach ( $configs as $config ) { 169 if ( !isset( $config['name'] ) ) { 170 throw new InvalidArgumentException( "Cannot register a backend with no name." ); 171 } 172 $name = $config['name']; 173 if ( isset( $this->backends[$name] ) ) { 174 throw new LogicException( "Backend with name '$name' already registered." ); 175 } elseif ( !isset( $config['class'] ) ) { 176 throw new InvalidArgumentException( "Backend with name '$name' has no class." ); 177 } 178 $class = $config['class']; 179 180 $config['domainId'] = 181 $config['domainId'] ?? $config['wikiId'] ?? $this->options->get( 'fallbackWikiId' ); 182 $config['readOnly'] = $config['readOnly'] ?? $readOnlyReason; 183 184 unset( $config['class'] ); // backend won't need this 185 $this->backends[$name] = [ 186 'class' => $class, 187 'config' => $config, 188 'instance' => null 189 ]; 190 } 191 } 192 193 /** 194 * Get the backend object with a given name 195 * 196 * @param string $name 197 * @return FileBackend 198 * @throws InvalidArgumentException 199 */ 200 public function get( $name ) { 201 // Lazy-load the actual backend instance 202 if ( !isset( $this->backends[$name]['instance'] ) ) { 203 $config = $this->config( $name ); 204 205 $class = $config['class']; 206 if ( $class === FileBackendMultiWrite::class ) { 207 // @todo How can we test this? What's the intended use-case? 208 foreach ( $config['backends'] as $index => $beConfig ) { 209 if ( isset( $beConfig['template'] ) ) { 210 // Config is just a modified version of a registered backend's. 211 // This should only be used when that config is used only by this backend. 212 $config['backends'][$index] += $this->config( $beConfig['template'] ); 213 } 214 } 215 } 216 217 $this->backends[$name]['instance'] = new $class( $config ); 218 } 219 220 return $this->backends[$name]['instance']; 221 } 222 223 /** 224 * Get the config array for a backend object with a given name 225 * 226 * @param string $name 227 * @return array Parameters to FileBackend::__construct() 228 * @throws InvalidArgumentException 229 */ 230 public function config( $name ) { 231 if ( !isset( $this->backends[$name] ) ) { 232 throw new InvalidArgumentException( "No backend defined with the name '$name'." ); 233 } 234 235 $config = $this->backends[$name]['config']; 236 237 return array_merge( 238 // Default backend parameters 239 [ 240 'mimeCallback' => [ $this, 'guessMimeInternal' ], 241 'obResetFunc' => 'wfResetOutputBuffers', 242 'streamMimeFunc' => [ StreamFile::class, 'contentTypeFromPath' ], 243 'tmpFileFactory' => $this->tmpFileFactory, 244 'statusWrapper' => [ Status::class, 'wrap' ], 245 'wanCache' => $this->wanCache, 246 'srvCache' => $this->srvCache, 247 'logger' => LoggerFactory::getInstance( 'FileOperation' ), 248 'profiler' => function ( $section ) { 249 return Profiler::instance()->scopedProfileIn( $section ); 250 } 251 ], 252 // Configured backend parameters 253 $config, 254 // Resolved backend parameters 255 [ 256 'class' => $this->backends[$name]['class'], 257 'lockManager' => 258 $this->lmgFactory->getLockManagerGroup( $config['domainId'] ) 259 ->get( $config['lockManager'] ), 260 'fileJournal' => isset( $config['fileJournal'] ) 261 ? $this->objectFactory->createObject( 262 $config['fileJournal'] + [ 'backend' => $name ], 263 [ 'specIsArg' => true, 'assertClass' => FileJournal::class ] ) 264 : new NullFileJournal 265 ] 266 ); 267 } 268 269 /** 270 * Get an appropriate backend object from a storage path 271 * 272 * @param string $storagePath 273 * @return FileBackend|null Backend or null on failure 274 */ 275 public function backendFromPath( $storagePath ) { 276 list( $backend, , ) = FileBackend::splitStoragePath( $storagePath ); 277 if ( $backend !== null && isset( $this->backends[$backend] ) ) { 278 return $this->get( $backend ); 279 } 280 281 return null; 282 } 283 284 /** 285 * @param string $storagePath 286 * @param string|null $content 287 * @param string|null $fsPath 288 * @return string 289 * @since 1.27 290 */ 291 public function guessMimeInternal( $storagePath, $content, $fsPath ) { 292 // Trust the extension of the storage path (caller must validate) 293 $ext = FileBackend::extensionFromPath( $storagePath ); 294 $type = $this->mimeAnalyzer->getMimeTypeFromExtensionOrNull( $ext ); 295 // For files without a valid extension (or one at all), inspect the contents 296 if ( !$type && $fsPath ) { 297 $type = $this->mimeAnalyzer->guessMimeType( $fsPath, false ); 298 } elseif ( !$type && strlen( $content ) ) { 299 $tmpFile = $this->tmpFileFactory->newTempFSFile( 'mime_', '' ); 300 file_put_contents( $tmpFile->getPath(), $content ); 301 $type = $this->mimeAnalyzer->guessMimeType( $tmpFile->getPath(), false ); 302 } 303 return $type ?: 'unknown/unknown'; 304 } 305} 306