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 For use by ServiceWiring 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 * @deprecated since 1.35, test framework should reset services between tests instead 86 */ 87 public static function destroySingleton() { 88 MediaWikiServices::getInstance()->resetServiceForTesting( 'FileBackendGroup' ); 89 } 90 91 /** 92 * @param ServiceOptions $options 93 * @param ConfiguredReadOnlyMode $configuredReadOnlyMode 94 * @param BagOStuff $srvCache 95 * @param WANObjectCache $wanCache 96 * @param MimeAnalyzer $mimeAnalyzer 97 * @param LockManagerGroupFactory $lmgFactory 98 * @param TempFSFileFactory $tmpFileFactory 99 * @param ObjectFactory $objectFactory 100 */ 101 public function __construct( 102 ServiceOptions $options, 103 ConfiguredReadOnlyMode $configuredReadOnlyMode, 104 BagOStuff $srvCache, 105 WANObjectCache $wanCache, 106 MimeAnalyzer $mimeAnalyzer, 107 LockManagerGroupFactory $lmgFactory, 108 TempFSFileFactory $tmpFileFactory, 109 ObjectFactory $objectFactory 110 ) { 111 $this->options = $options; 112 $this->srvCache = $srvCache; 113 $this->wanCache = $wanCache; 114 $this->mimeAnalyzer = $mimeAnalyzer; 115 $this->lmgFactory = $lmgFactory; 116 $this->tmpFileFactory = $tmpFileFactory; 117 $this->objectFactory = $objectFactory; 118 119 // Register explicitly defined backends 120 $this->register( $options->get( 'FileBackends' ), $configuredReadOnlyMode->getReason() ); 121 122 $autoBackends = []; 123 // Automatically create b/c backends for file repos... 124 $repos = array_merge( 125 $options->get( 'ForeignFileRepos' ), [ $options->get( 'LocalFileRepo' ) ] ); 126 foreach ( $repos as $info ) { 127 $backendName = $info['backend']; 128 if ( is_object( $backendName ) || isset( $this->backends[$backendName] ) ) { 129 continue; // already defined (or set to the object for some reason) 130 } 131 $repoName = $info['name']; 132 // Local vars that used to be FSRepo members... 133 $directory = $info['directory']; 134 $deletedDir = $info['deletedDir'] ?? false; // deletion disabled 135 $thumbDir = $info['thumbDir'] ?? "{$directory}/thumb"; 136 $transcodedDir = $info['transcodedDir'] ?? "{$directory}/transcoded"; 137 // Get the FS backend configuration 138 $autoBackends[] = [ 139 'name' => $backendName, 140 'class' => FSFileBackend::class, 141 'lockManager' => 'fsLockManager', 142 'containerPaths' => [ 143 "{$repoName}-public" => "{$directory}", 144 "{$repoName}-thumb" => $thumbDir, 145 "{$repoName}-transcoded" => $transcodedDir, 146 "{$repoName}-deleted" => $deletedDir, 147 "{$repoName}-temp" => "{$directory}/temp" 148 ], 149 'fileMode' => $info['fileMode'] ?? 0644, 150 'directoryMode' => $options->get( 'DirectoryMode' ), 151 ]; 152 } 153 154 // Register implicitly defined backends 155 $this->register( $autoBackends, $configuredReadOnlyMode->getReason() ); 156 } 157 158 /** 159 * Register an array of file backend configurations 160 * 161 * @param array[] $configs 162 * @param string|null $readOnlyReason 163 * @throws InvalidArgumentException 164 */ 165 protected function register( array $configs, $readOnlyReason = null ) { 166 foreach ( $configs as $config ) { 167 if ( !isset( $config['name'] ) ) { 168 throw new InvalidArgumentException( "Cannot register a backend with no name." ); 169 } 170 $name = $config['name']; 171 if ( isset( $this->backends[$name] ) ) { 172 throw new LogicException( "Backend with name '$name' already registered." ); 173 } elseif ( !isset( $config['class'] ) ) { 174 throw new InvalidArgumentException( "Backend with name '$name' has no class." ); 175 } 176 $class = $config['class']; 177 178 $config['domainId'] = 179 $config['domainId'] ?? $config['wikiId'] ?? $this->options->get( 'fallbackWikiId' ); 180 $config['readOnly'] = $config['readOnly'] ?? $readOnlyReason; 181 182 unset( $config['class'] ); // backend won't need this 183 $this->backends[$name] = [ 184 'class' => $class, 185 'config' => $config, 186 'instance' => null 187 ]; 188 } 189 } 190 191 /** 192 * Get the backend object with a given name 193 * 194 * @param string $name 195 * @return FileBackend 196 * @throws InvalidArgumentException 197 */ 198 public function get( $name ) { 199 // Lazy-load the actual backend instance 200 if ( !isset( $this->backends[$name]['instance'] ) ) { 201 $config = $this->config( $name ); 202 203 $class = $config['class']; 204 if ( $class === FileBackendMultiWrite::class ) { 205 // @todo How can we test this? What's the intended use-case? 206 foreach ( $config['backends'] as $index => $beConfig ) { 207 if ( isset( $beConfig['template'] ) ) { 208 // Config is just a modified version of a registered backend's. 209 // This should only be used when that config is used only by this backend. 210 $config['backends'][$index] += $this->config( $beConfig['template'] ); 211 } 212 } 213 } 214 215 $this->backends[$name]['instance'] = new $class( $config ); 216 } 217 218 return $this->backends[$name]['instance']; 219 } 220 221 /** 222 * Get the config array for a backend object with a given name 223 * 224 * @param string $name 225 * @return array Parameters to FileBackend::__construct() 226 * @throws InvalidArgumentException 227 */ 228 public function config( $name ) { 229 if ( !isset( $this->backends[$name] ) ) { 230 throw new InvalidArgumentException( "No backend defined with the name '$name'." ); 231 } 232 233 $config = $this->backends[$name]['config']; 234 235 return array_merge( 236 // Default backend parameters 237 [ 238 'mimeCallback' => [ $this, 'guessMimeInternal' ], 239 'obResetFunc' => 'wfResetOutputBuffers', 240 'streamMimeFunc' => [ StreamFile::class, 'contentTypeFromPath' ], 241 'tmpFileFactory' => $this->tmpFileFactory, 242 'statusWrapper' => [ Status::class, 'wrap' ], 243 'wanCache' => $this->wanCache, 244 'srvCache' => $this->srvCache, 245 'logger' => LoggerFactory::getInstance( 'FileOperation' ), 246 'profiler' => static function ( $section ) { 247 return Profiler::instance()->scopedProfileIn( $section ); 248 } 249 ], 250 // Configured backend parameters 251 $config, 252 // Resolved backend parameters 253 [ 254 'class' => $this->backends[$name]['class'], 255 'lockManager' => 256 $this->lmgFactory->getLockManagerGroup( $config['domainId'] ) 257 ->get( $config['lockManager'] ), 258 'fileJournal' => isset( $config['fileJournal'] ) 259 ? $this->objectFactory->createObject( 260 $config['fileJournal'] + [ 'backend' => $name ], 261 [ 'specIsArg' => true, 'assertClass' => FileJournal::class ] ) 262 : new NullFileJournal 263 ] 264 ); 265 } 266 267 /** 268 * Get an appropriate backend object from a storage path 269 * 270 * @param string $storagePath 271 * @return FileBackend|null Backend or null on failure 272 */ 273 public function backendFromPath( $storagePath ) { 274 list( $backend, , ) = FileBackend::splitStoragePath( $storagePath ); 275 if ( $backend !== null && isset( $this->backends[$backend] ) ) { 276 return $this->get( $backend ); 277 } 278 279 return null; 280 } 281 282 /** 283 * @param string $storagePath 284 * @param string|null $content 285 * @param string|null $fsPath 286 * @return string 287 * @since 1.27 288 */ 289 public function guessMimeInternal( $storagePath, $content, $fsPath ) { 290 // Trust the extension of the storage path (caller must validate) 291 $ext = FileBackend::extensionFromPath( $storagePath ); 292 $type = $this->mimeAnalyzer->getMimeTypeFromExtensionOrNull( $ext ); 293 // For files without a valid extension (or one at all), inspect the contents 294 if ( !$type && $fsPath ) { 295 $type = $this->mimeAnalyzer->guessMimeType( $fsPath, false ); 296 } elseif ( !$type && strlen( $content ) ) { 297 $tmpFile = $this->tmpFileFactory->newTempFSFile( 'mime_', '' ); 298 file_put_contents( $tmpFile->getPath(), $content ); 299 $type = $this->mimeAnalyzer->guessMimeType( $tmpFile->getPath(), false ); 300 } 301 return $type ?: 'unknown/unknown'; 302 } 303} 304