Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.37% covered (warning)
66.37%
75 / 113
44.44% covered (danger)
44.44%
8 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Engine
66.37% covered (warning)
66.37%
75 / 113
44.44% covered (danger)
44.44%
8 / 18
74.41
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
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% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 persistAudio
66.67% covered (warning)
66.67%
14 / 21
0.00% covered (danger)
0.00%
0 / 1
5.93
 getPersistedAudio
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 isPersisted
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getError
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 setError
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 clearError
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 updateFileExpiry
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 checkLanguageSupport
58.82% covered (warning)
58.82%
10 / 17
0.00% covered (danger)
0.00%
0 / 1
5.12
 convertWavToMp3
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
2.00
 getFileProperties
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
1
 getFileStoragePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFileName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFullFileStoragePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 generateExpiryTs
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getSupportedLanguages
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getUploadPath
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\Phonos\Engine;
4
5use BagOStuff;
6use FileBackend;
7use FileBackendGroup;
8use FSFileBackend;
9use Language;
10use MediaWiki\Config\Config;
11use MediaWiki\Extension\Phonos\Exception\PhonosException;
12use MediaWiki\Http\HttpRequestFactory;
13use MediaWiki\Logger\LoggerFactory;
14use MediaWiki\MainConfigNames;
15use MediaWiki\Shell\CommandFactory;
16use MediaWiki\Status\Status;
17use NullLockManager;
18use ReflectionClass;
19use WANObjectCache;
20
21/**
22 * Contains logic common to all Engines.
23 */
24abstract 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}