Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
66.37% |
75 / 113 |
|
44.44% |
8 / 18 |
CRAP | |
0.00% |
0 / 1 |
Engine | |
66.37% |
75 / 113 |
|
44.44% |
8 / 18 |
74.41 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
2 | |||
register | n/a |
0 / 0 |
n/a |
0 / 0 |
0 | |||||
getFileBackend | n/a |
0 / 0 |
n/a |
0 / 0 |
3 | |||||
getFileUrl | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
persistAudio | |
66.67% |
14 / 21 |
|
0.00% |
0 / 1 |
5.93 | |||
getPersistedAudio | |
80.00% |
4 / 5 |
|
0.00% |
0 / 1 |
2.03 | |||
isPersisted | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getError | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
6 | |||
setError | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
clearError | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
updateFileExpiry | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
checkLanguageSupport | |
58.82% |
10 / 17 |
|
0.00% |
0 / 1 |
5.12 | |||
convertWavToMp3 | |
90.91% |
10 / 11 |
|
0.00% |
0 / 1 |
2.00 | |||
getFileProperties | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
1 | |||
getFileStoragePath | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getFileName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
getFullFileStoragePath | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
generateExpiryTs | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getSupportedLanguages | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
getUploadPath | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\Phonos\Engine; |
4 | |
5 | use BagOStuff; |
6 | use FileBackend; |
7 | use FileBackendGroup; |
8 | use FSFileBackend; |
9 | use Language; |
10 | use MediaWiki\Config\Config; |
11 | use MediaWiki\Extension\Phonos\Exception\PhonosException; |
12 | use MediaWiki\Http\HttpRequestFactory; |
13 | use MediaWiki\Logger\LoggerFactory; |
14 | use MediaWiki\MainConfigNames; |
15 | use MediaWiki\Shell\CommandFactory; |
16 | use MediaWiki\Status\Status; |
17 | use NullLockManager; |
18 | use ReflectionClass; |
19 | use WANObjectCache; |
20 | |
21 | /** |
22 | * Contains logic common to all Engines. |
23 | */ |
24 | abstract class Engine implements EngineInterface { |
25 | |
26 | /** |
27 | * Version for cache invalidation. |
28 | * |
29 | * WARNING: Changing this value will cause *all* Phonos files to be regenerated! |
30 | * |
31 | * After changing, please also run the deleteOldPhonosFiles.php script |
32 | * with the appropriate timestamp to delete old orphaned files. |
33 | * |
34 | * @var int |
35 | */ |
36 | private const CACHE_VERSION = 1; |
37 | |
38 | /** @var int|null Minimum file size in bytes. Null for no minimum. See T324239 */ |
39 | protected const MIN_FILE_SIZE = null; |
40 | |
41 | /** @var string Prefix directory name when persisting files to storage. */ |
42 | public const STORAGE_PREFIX = 'phonos-render'; |
43 | |
44 | /** @var HttpRequestFactory */ |
45 | protected $requestFactory; |
46 | |
47 | /** @var CommandFactory */ |
48 | protected $commandFactory; |
49 | |
50 | /** @var FileBackend */ |
51 | protected $fileBackend; |
52 | |
53 | /** @var string */ |
54 | protected $apiProxy; |
55 | |
56 | /** @var string */ |
57 | protected $lamePath; |
58 | |
59 | /** @var string */ |
60 | protected $uploadPath; |
61 | |
62 | /** @var string */ |
63 | private $engineName; |
64 | |
65 | /** @var int Time in days we want to persist the file for */ |
66 | protected $fileExpiry; |
67 | |
68 | private BagOStuff $stash; |
69 | |
70 | /** @var WANObjectCache */ |
71 | protected $wanCache; |
72 | |
73 | /** @var Language */ |
74 | private $contentLanguage; |
75 | |
76 | /** @var Config */ |
77 | protected $config; |
78 | |
79 | /** |
80 | * @param HttpRequestFactory $requestFactory |
81 | * @param CommandFactory $commandFactory |
82 | * @param FileBackendGroup $fileBackendGroup |
83 | * @param BagOStuff $stash |
84 | * @param WANObjectCache $wanCache |
85 | * @param Language $contentLanguage |
86 | * @param Config $config |
87 | */ |
88 | public function __construct( |
89 | HttpRequestFactory $requestFactory, |
90 | CommandFactory $commandFactory, |
91 | FileBackendGroup $fileBackendGroup, |
92 | BagOStuff $stash, |
93 | WANObjectCache $wanCache, |
94 | Language $contentLanguage, |
95 | Config $config |
96 | ) { |
97 | $this->requestFactory = $requestFactory; |
98 | $this->commandFactory = $commandFactory; |
99 | $this->fileBackend = self::getFileBackend( $fileBackendGroup, $config ); |
100 | $this->stash = $stash; |
101 | $this->wanCache = $wanCache; |
102 | $this->contentLanguage = $contentLanguage; |
103 | $this->config = $config; |
104 | $this->apiProxy = $config->get( 'PhonosApiProxy' ); |
105 | $this->lamePath = $config->get( 'PhonosLame' ); |
106 | $this->uploadPath = $config->get( 'PhonosPath' ) ?: |
107 | $config->get( MainConfigNames::UploadPath ) . '/' . self::STORAGE_PREFIX; |
108 | // Using ReflectionClass to get the unqualified class name is actually faster than doing string operations. |
109 | $this->engineName = ( new ReflectionClass( get_class( $this ) ) )->getShortName(); |
110 | |
111 | // Only used if filebackend supports ATTR_METADATA |
112 | $this->fileExpiry = $config->get( 'PhonosFileExpiry' ); |
113 | |
114 | $this->register(); |
115 | } |
116 | |
117 | abstract protected function register(): void; |
118 | |
119 | /** |
120 | * Get either the configured FileBackend, or create a Phonos-specific FSFileBackend. |
121 | * |
122 | * @param FileBackendGroup $fileBackendGroup |
123 | * @param Config $config |
124 | * @return FileBackend |
125 | * @codeCoverageIgnore |
126 | */ |
127 | public static function getFileBackend( FileBackendGroup $fileBackendGroup, Config $config ): FileBackend { |
128 | if ( $config->get( 'PhonosFileBackend' ) ) { |
129 | return $fileBackendGroup->get( $config->get( 'PhonosFileBackend' ) ); |
130 | } |
131 | |
132 | $uploadDirectory = $config->get( 'PhonosFileBackendDirectory' ) ?: |
133 | $config->get( MainConfigNames::UploadDirectory ) . '/' . self::STORAGE_PREFIX; |
134 | |
135 | return new FSFileBackend( [ |
136 | 'name' => 'phonos-backend', |
137 | 'basePath' => $uploadDirectory, |
138 | // NOTE: We intentionally use a blank 'domainId' since Phonos files with identical |
139 | // parameters (including language) won't differ cross-wiki and should be shared. |
140 | // Similarly we set the 'containerPaths', which effectively tells FileBackend to |
141 | // bypass using the 'domainId' when building paths. This is to prevent the asymmetry |
142 | // in path names used by FSFileBackend and others such as Swift. However, all files |
143 | // are under a dedicated directory with the name self::STORAGE_PREFIX, which should |
144 | // be enough to prevent collisions with other backends using the same storage system. |
145 | // If this is undesired, set $wgPhonosFileBackendDirectory and/or $wgPhonosFileBackend |
146 | // accordingly, along with the user-facing path specified by $wgPhonosPath. |
147 | 'domainId' => '', |
148 | 'containerPaths' => [ self::STORAGE_PREFIX => $uploadDirectory ], |
149 | 'lockManager' => new NullLockManager( [] ), |
150 | 'fileMode' => 0777, |
151 | 'directoryMode' => 0777, |
152 | 'obResetFunc' => 'wfResetOutputBuffers', |
153 | 'streamMimeFunc' => [ 'StreamFile', 'contentTypeFromPath' ], |
154 | 'statusWrapper' => [ 'Status', 'wrap' ], |
155 | 'logger' => LoggerFactory::getInstance( 'phonos' ), |
156 | ] ); |
157 | } |
158 | |
159 | /** |
160 | * Get the relative URL to the persisted file. |
161 | * |
162 | * @param AudioParams $params |
163 | * @return string |
164 | */ |
165 | public function getFileUrl( AudioParams $params ): string { |
166 | return $this->getFileProperties( $params )['dest_url']; |
167 | } |
168 | |
169 | /** |
170 | * Persist the given audio data using the configured storage backend. |
171 | * |
172 | * @param AudioParams $params |
173 | * @param string $data |
174 | * @return void |
175 | * @throws PhonosException |
176 | */ |
177 | final public function persistAudio( AudioParams $params, string $data ): void { |
178 | if ( static::MIN_FILE_SIZE && strlen( $data ) < static::MIN_FILE_SIZE ) { |
179 | throw new PhonosException( 'phonos-empty-file-error', [ 'text' ] ); |
180 | } |
181 | |
182 | $status = $this->fileBackend->prepare( [ |
183 | 'dir' => $this->getFileStoragePath( $params ), |
184 | ] ); |
185 | if ( !$status->isOK() ) { |
186 | throw new PhonosException( 'phonos-directory-error', [ |
187 | Status::wrap( $status )->getMessage()->text(), |
188 | ] ); |
189 | } |
190 | |
191 | // Create the file. |
192 | $status = $this->fileBackend->quickCreate( [ |
193 | 'dst' => $this->getFullFileStoragePath( $params ), |
194 | 'content' => $data, |
195 | 'overwriteSame' => true, |
196 | 'headers' => [ |
197 | 'X-Delete-At' => $this->generateExpiryTs() |
198 | ], |
199 | ] ); |
200 | |
201 | if ( !$status->isOK() ) { |
202 | throw new PhonosException( 'phonos-storage-error', [ |
203 | Status::wrap( $status )->getMessage()->text() |
204 | ] ); |
205 | } |
206 | } |
207 | |
208 | /** |
209 | * Fetch the contents of the persisted file in the storage backend, or null if the file doesn't exist. |
210 | * |
211 | * @param AudioParams $params |
212 | * @return string|null base64 data, or null if the file doesn't exist. |
213 | */ |
214 | final public function getPersistedAudio( AudioParams $params ): ?string { |
215 | if ( !$this->isPersisted( $params ) ) { |
216 | return null; |
217 | } |
218 | return $this->fileBackend->getFileContents( [ |
219 | 'src' => $this->getFullFileStoragePath( $params ), |
220 | ] ); |
221 | } |
222 | |
223 | /** |
224 | * Is there a persisted file for the given parameters? |
225 | * |
226 | * @param AudioParams $params |
227 | * @return bool |
228 | */ |
229 | final public function isPersisted( AudioParams $params ): bool { |
230 | return (bool)$this->fileBackend->fileExists( [ |
231 | 'src' => $this->getFullFileStoragePath( $params ), |
232 | ] ); |
233 | } |
234 | |
235 | /** |
236 | * Get the previous error recorded for the given parameters. |
237 | * |
238 | * @param AudioParams $params |
239 | * @return ?array Message key and parameters, or null if no error |
240 | */ |
241 | final public function getError( AudioParams $params ): ?array { |
242 | return $this->stash->get( |
243 | $this->stash->makeKey( 'phonos', 'engine-error', |
244 | $params->getIpa(), $params->getText(), $params->getLang() ) |
245 | ) ?: null; |
246 | } |
247 | |
248 | /** |
249 | * Record the error encountered for the given parameters. |
250 | * |
251 | * @param AudioParams $params |
252 | * @param array $error Message key and parameters |
253 | */ |
254 | final public function setError( AudioParams $params, array $error ): void { |
255 | $this->stash->set( |
256 | $this->stash->makeKey( 'phonos', 'engine-error', |
257 | $params->getIpa(), $params->getText(), $params->getLang() ), |
258 | $error |
259 | ); |
260 | } |
261 | |
262 | /** |
263 | * Clear the previous error recorded for the given parameters. |
264 | * |
265 | * @param AudioParams $params |
266 | */ |
267 | final public function clearError( AudioParams $params ): void { |
268 | $this->stash->delete( |
269 | $this->stash->makeKey( 'phonos', 'engine-error', |
270 | $params->getIpa(), $params->getText(), $params->getLang() ) |
271 | ); |
272 | } |
273 | |
274 | /** |
275 | * Update file expiry when supported by the file backend |
276 | * |
277 | * @param AudioParams $params |
278 | * @return void |
279 | */ |
280 | final public function updateFileExpiry( AudioParams $params ): void { |
281 | if ( $this->fileBackend->hasFeatures( FileBackend::ATTR_HEADERS ) ) { |
282 | $this->fileBackend->quickDescribe( [ |
283 | 'src' => $this->getFullFileStoragePath( $params ), |
284 | 'headers' => [ |
285 | 'X-Delete-At' => $this->generateExpiryTs(), |
286 | ], |
287 | ] ); |
288 | } |
289 | } |
290 | |
291 | /** |
292 | * For a given language, check that it's supported by the engine. |
293 | * @param string $lang Language code to check. Will be returned if valid. |
294 | * @return string The normalized language code. |
295 | * @throws PhonosException If the language is not supported. |
296 | */ |
297 | public function checkLanguageSupport( string $lang ): string { |
298 | // If an engine doesn't provide a list of supported languages, assume this one is supported. |
299 | $supportedLangs = $this->getSupportedLanguages(); |
300 | if ( $supportedLangs === null ) { |
301 | return $lang; |
302 | } |
303 | |
304 | // Normalize and check for the requested language, returning it if it's supported. |
305 | $normalizedLang = strtolower( strtr( $lang, '_', '-' ) ); |
306 | $normalizedLangs = array_map( static function ( $l ) { |
307 | return strtolower( strtr( $l, '_', '-' ) ); |
308 | }, $supportedLangs ); |
309 | $supportedLangKey = array_search( $normalizedLang, $normalizedLangs ); |
310 | if ( $supportedLangKey !== false ) { |
311 | return $supportedLangs[$supportedLangKey]; |
312 | } |
313 | |
314 | // Make a list of supported languages that are a superstring of the given one. |
315 | $suggestions = array_filter( $supportedLangs, static function ( $sl ) use ( $lang ) { |
316 | return stripos( $sl, $lang ) !== false; |
317 | } ); |
318 | if ( count( $suggestions ) === 0 ) { |
319 | throw new PhonosException( 'phonos-unsupported-language', [ $lang ] ); |
320 | } else { |
321 | $suggestionList = $this->contentLanguage->listToText( $suggestions ); |
322 | throw new PhonosException( 'phonos-unsupported-language-with-suggestions', [ $lang, $suggestionList ] ); |
323 | } |
324 | } |
325 | |
326 | /** |
327 | * Convert the given WAV data into MP3. |
328 | * |
329 | * @param string $data |
330 | * @return string |
331 | * @throws PhonosException |
332 | */ |
333 | final public function convertWavToMp3( string $data ): string { |
334 | $out = $this->commandFactory |
335 | ->createBoxed( 'phonos' ) |
336 | ->disableNetwork() |
337 | ->firejailDefaultSeccomp() |
338 | ->routeName( 'phonos-mp3-convert' ) |
339 | ->params( $this->lamePath, '-', '-' ) |
340 | ->stdin( $data ) |
341 | ->execute(); |
342 | if ( $out->getExitCode() !== 0 ) { |
343 | throw new PhonosException( 'phonos-audio-conversion-error', [ $out->getStderr() ] ); |
344 | } |
345 | return $out->getStdout(); |
346 | } |
347 | |
348 | /** |
349 | * Get various storage properties about the persisted file. |
350 | * |
351 | * @param AudioParams $params |
352 | * @return string[] with keys 'dest_storage_path', 'dest_url', 'file_name' |
353 | */ |
354 | private function getFileProperties( AudioParams $params ): array { |
355 | $baseStoragePath = $this->fileBackend->getRootStoragePath() . '/' . self::STORAGE_PREFIX; |
356 | $cacheOptions = [ $this->engineName, |
357 | $params->getIpa(), |
358 | $params->getText(), |
359 | $params->getLang(), |
360 | self::CACHE_VERSION ]; |
361 | $fileCacheName = \Wikimedia\base_convert( sha1( implode( '|', $cacheOptions ) ), 16, 36, 31 ); |
362 | $filePrefixEnd = "{$fileCacheName[0]}/{$fileCacheName[1]}"; |
363 | $fileName = "$fileCacheName.mp3"; |
364 | return [ |
365 | 'fileName' => $fileName, |
366 | 'dest_storage_path' => "$baseStoragePath/$filePrefixEnd", |
367 | 'dest_url' => "{$this->uploadPath}/$filePrefixEnd/{$fileName}", |
368 | ]; |
369 | } |
370 | |
371 | /** |
372 | * Get the internal storage path to the persisted file, whether it exists or not. |
373 | * |
374 | * @param AudioParams $params |
375 | * @return string |
376 | */ |
377 | private function getFileStoragePath( AudioParams $params ): string { |
378 | return $this->getFileProperties( $params )[ 'dest_storage_path' ]; |
379 | } |
380 | |
381 | /** |
382 | * Get the unique filename for the given set of Phonos parameters, including the file extension. |
383 | * |
384 | * @param AudioParams $params |
385 | * @return string |
386 | */ |
387 | public function getFileName( AudioParams $params ): string { |
388 | return $this->getFileProperties( $params )['fileName']; |
389 | } |
390 | |
391 | /** |
392 | * Get the full path to the file. |
393 | * @param AudioParams $params |
394 | * @return string |
395 | */ |
396 | private function getFullFileStoragePath( AudioParams $params ): string { |
397 | return $this->getFileStoragePath( $params ) . '/' . $this->getFileName( $params ); |
398 | } |
399 | |
400 | /** |
401 | * Generate file expiry with some deviations |
402 | * to minimize flooding on object expiration |
403 | * @return int |
404 | */ |
405 | private function generateExpiryTs(): int { |
406 | // convert days to seconds |
407 | $ttl = $this->fileExpiry * 86400; |
408 | return time() + rand( intval( $ttl * 0.8 ), $ttl ); |
409 | } |
410 | |
411 | /** |
412 | * @inheritDoc |
413 | */ |
414 | public function getSupportedLanguages(): ?array { |
415 | return null; |
416 | } |
417 | |
418 | /** |
419 | * Expose upload path for use in maintenance scripts. |
420 | * |
421 | * @return string |
422 | */ |
423 | final public function getUploadPath(): string { |
424 | return $this->uploadPath; |
425 | } |
426 | |
427 | } |