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