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