Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.91% covered (warning)
87.91%
189 / 215
42.86% covered (danger)
42.86%
6 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiDetailRetriever
87.91% covered (warning)
87.91%
189 / 215
42.86% covered (danger)
42.86%
6 / 14
63.95
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 sendApiRequest
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
2.31
 getImportDetails
98.15% covered (success)
98.15%
53 / 54
0.00% covered (danger)
0.00%
0 / 1
7
 reduceTitleList
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 getMoreRevisions
79.31% covered (warning)
79.31%
23 / 29
0.00% covered (danger)
0.00%
0 / 1
9.72
 checkRevisionCount
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 checkMaxRevisionAggregatedBytes
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 getFileRevisionsFromImageInfo
65.00% covered (warning)
65.00%
13 / 20
0.00% covered (danger)
0.00%
0 / 1
10.74
 getTextRevisionsFromRevisionsInfo
64.29% covered (warning)
64.29%
9 / 14
0.00% covered (danger)
0.00%
0 / 1
6.14
 getBaseParams
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 addTextRevisionsToParams
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 addFileRevisionsToParams
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
3
 addTemplatesToParams
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 addCategoriesToParams
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
1<?php
2
3namespace FileImporter\Remote\MediaWiki;
4
5use FileImporter\Data\FileRevision;
6use FileImporter\Data\FileRevisions;
7use FileImporter\Data\ImportDetails;
8use FileImporter\Data\SourceUrl;
9use FileImporter\Data\TextRevision;
10use FileImporter\Data\TextRevisions;
11use FileImporter\Exceptions\HttpRequestException;
12use FileImporter\Exceptions\ImportException;
13use FileImporter\Exceptions\LocalizedImportException;
14use FileImporter\Interfaces\DetailRetriever;
15use FileImporter\Services\Http\HttpRequestExecutor;
16use MediaWiki\Config\ConfigException;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Revision\SlotRecord;
19use MediaWiki\Title\TitleValue;
20use Psr\Log\LoggerInterface;
21use Psr\Log\NullLogger;
22
23/**
24 * @license GPL-2.0-or-later
25 * @author Addshore
26 */
27class ApiDetailRetriever implements DetailRetriever {
28    use MediaWikiSourceUrlParser;
29
30    /**
31     * @var string Placeholder name replacing usernames that have been suppressed as part of
32     * a steward action on the source site.
33     */
34    private $suppressedUsername;
35    private int $maxRevisions;
36    private int $maxAggregatedBytes;
37
38    private const API_RESULT_LIMIT = 500;
39    private const MAX_REVISIONS = 100;
40    private const MAX_AGGREGATED_BYTES = 250000000;
41
42    /**
43     * @throws ConfigException when $wgFileImporterAccountForSuppressedUsername is invalid
44     */
45    public function __construct(
46        private readonly HttpApiLookup $httpApiLookup,
47        private readonly HttpRequestExecutor $httpRequestExecutor,
48        private readonly int $maxBytes,
49        private readonly LoggerInterface $logger = new NullLogger(),
50    ) {
51        $config = MediaWikiServices::getInstance()->getMainConfig();
52
53        $this->maxRevisions = (int)$config->get( 'FileImporterMaxRevisions' );
54        $this->maxAggregatedBytes = (int)$config->get( 'FileImporterMaxAggregatedBytes' );
55        $this->suppressedUsername = $config->get( 'FileImporterAccountForSuppressedUsername' );
56        if ( !MediaWikiServices::getInstance()->getUserNameUtils()->isValid( $this->suppressedUsername ) ) {
57            throw new ConfigException(
58                'Invalid username configured in wgFileImporterAccountForSuppressedUsername: "' .
59                $this->suppressedUsername . '"'
60            );
61        }
62    }
63
64    /**
65     * @return array[]
66     * @throws ImportException when the request failed
67     */
68    private function sendApiRequest( SourceUrl $sourceUrl, array $apiParameters ) {
69        $apiUrl = $this->httpApiLookup->getApiUrl( $sourceUrl );
70
71        try {
72            $imageInfoRequest = $this->httpRequestExecutor->execute( $apiUrl, $apiParameters );
73        } catch ( HttpRequestException $e ) {
74            throw new LocalizedImportException( [ 'fileimporter-api-failedtogetinfo',
75                $apiUrl ], $e );
76        }
77        $requestData = json_decode( $imageInfoRequest->getContent(), true );
78        return $requestData;
79    }
80
81    /**
82     * @throws ImportException e.g. when the file couldn't be found
83     */
84    public function getImportDetails( SourceUrl $sourceUrl ): ImportDetails {
85        $params = $this->getBaseParams( $sourceUrl );
86        $params = $this->addFileRevisionsToParams( $params );
87        $params = $this->addTextRevisionsToParams( $params );
88        $params = $this->addTemplatesToParams( $params );
89        $params = $this->addCategoriesToParams( $params );
90
91        $requestData = $this->sendApiRequest( $sourceUrl, $params );
92
93        if ( count( $requestData['query']['pages'] ?? [] ) !== 1 ) {
94            $this->logger->warning(
95                'No pages returned by the API',
96                [
97                    'sourceUrl' => $sourceUrl->getUrl(),
98                    'apiParameters' => $params,
99                ]
100            );
101            throw new LocalizedImportException( 'fileimporter-api-nopagesreturned' );
102        }
103
104        /** @var array $pageInfoData */
105        $pageInfoData = array_last( $requestData['query']['pages'] );
106        '@phan-var array $pageInfoData';
107
108        if ( ( $pageInfoData['missing'] ?? false ) !== false ) {
109            if ( ( $pageInfoData['imagerepository'] ?? null ) === 'shared' ) {
110                throw new LocalizedImportException(
111                    [ 'fileimporter-cantimportfromsharedrepo', $sourceUrl->getHost() ]
112                );
113            }
114            throw new LocalizedImportException( 'fileimporter-cantimportmissingfile' );
115        }
116
117        if ( empty( $pageInfoData['imageinfo'] ) || empty( $pageInfoData['revisions'] ) ) {
118            $this->logger->warning(
119                'Bad image or revision info returned by the API',
120                [
121                    'sourceUrl' => $sourceUrl->getUrl(),
122                    'apiParameters' => $params,
123                ]
124            );
125            throw new LocalizedImportException( 'fileimporter-api-badinfo' );
126        }
127
128        // FIXME: Isn't this misplaced here, *before* more revisions are fetched?
129        $this->checkRevisionCount( $sourceUrl, $pageInfoData );
130        $this->checkMaxRevisionAggregatedBytes( $pageInfoData );
131
132        while ( array_key_exists( 'continue', $requestData ) ) {
133            $this->getMoreRevisions( $sourceUrl, $requestData, $pageInfoData );
134        }
135
136        $pageTitle = $pageInfoData['title'];
137        $pageLanguage = $pageInfoData['pagelanguagehtmlcode'] ?? null;
138
139        $imageInfoData = $pageInfoData['imageinfo'];
140        $revisionsData = $pageInfoData['revisions'];
141        $fileRevisions = $this->getFileRevisionsFromImageInfo( $imageInfoData, $pageTitle );
142        $textRevisions = $this->getTextRevisionsFromRevisionsInfo( $revisionsData, $pageTitle );
143        $templates = $this->reduceTitleList( $pageInfoData['templates'] ?? [], NS_TEMPLATE );
144        $categories = $this->reduceTitleList( $pageInfoData['categories'] ?? [], NS_CATEGORY );
145
146        $titleAfterColon = array_last( explode( ':', $pageInfoData['title'], 2 ) );
147
148        $importDetails = new ImportDetails(
149            $sourceUrl,
150            new TitleValue( NS_FILE, $titleAfterColon ),
151            $textRevisions,
152            $fileRevisions
153        );
154        // FIXME: Better use constructor parameters instead of setters?
155        $importDetails->setPageLanguage( $pageLanguage );
156        $importDetails->setTemplates( $templates );
157        $importDetails->setCategories( $categories );
158
159        return $importDetails;
160    }
161
162    /**
163     * @param array[] $results Result set as returned by the API
164     * @param int $namespace
165     *
166     * @return string[]
167     */
168    private function reduceTitleList( array $results, int $namespace ): array {
169        $titles = [];
170        foreach ( $results as $result ) {
171            if ( $result['ns'] === $namespace ) {
172                $titles[] = $result['title'];
173            }
174        }
175        return $titles;
176    }
177
178    /**
179     * Fetches the next set of revisions unless the number of revisions
180     * exceeds the max revisions limit
181     *
182     * @param SourceUrl $sourceUrl
183     * @param array[] &$requestData
184     * @param array[] &$pageInfoData
185     *
186     * @throws ImportException
187     */
188    private function getMoreRevisions(
189        SourceUrl $sourceUrl,
190        array &$requestData,
191        array &$pageInfoData
192    ): void {
193        $rvContinue = $requestData['continue']['rvcontinue'] ?? null;
194        $iiStart = $requestData['continue']['iistart'] ?? null;
195        $tlContinue = $requestData['continue']['tlcontinue'] ?? null;
196        $clContinue = $requestData['continue']['clcontinue'] ?? null;
197
198        $params = $this->getBaseParams( $sourceUrl );
199
200        if ( $iiStart ) {
201            $params = $this->addFileRevisionsToParams( $params, $iiStart );
202        }
203
204        if ( $rvContinue ) {
205            $params = $this->addTextRevisionsToParams( $params, $rvContinue );
206        }
207
208        if ( $tlContinue ) {
209            $params = $this->addTemplatesToParams( $params, $tlContinue );
210        }
211
212        if ( $clContinue ) {
213            $params = $this->addCategoriesToParams( $params, $clContinue );
214        }
215
216        $requestData = $this->sendApiRequest( $sourceUrl, $params );
217
218        $newPageInfoData = array_last( $requestData['query']['pages'] );
219
220        if ( array_key_exists( 'revisions', $newPageInfoData ) ) {
221            $pageInfoData['revisions'] =
222                array_merge( $pageInfoData['revisions'], $newPageInfoData['revisions'] );
223        }
224
225        if ( array_key_exists( 'imageinfo', $newPageInfoData ) ) {
226            $pageInfoData['imageinfo'] =
227                array_merge( $pageInfoData['imageinfo'], $newPageInfoData['imageinfo'] );
228        }
229
230        if ( array_key_exists( 'templates', $newPageInfoData ) ) {
231            $pageInfoData['templates'] =
232                array_merge( $pageInfoData['templates'], $newPageInfoData['templates'] );
233        }
234
235        if ( array_key_exists( 'categories', $newPageInfoData ) ) {
236            $pageInfoData['categories'] =
237                array_merge( $pageInfoData['categories'], $newPageInfoData['categories'] );
238        }
239
240        $this->checkRevisionCount( $sourceUrl, $pageInfoData );
241        $this->checkMaxRevisionAggregatedBytes( $pageInfoData );
242    }
243
244    /**
245     * Throws an exception if the number of revisions to be imported exceeds
246     * the maximum revision limit
247     *
248     * @param SourceUrl $sourceUrl
249     * @param array[] $pageInfoData
250     *
251     * @throws ImportException when exceeding the acceptable maximum
252     */
253    private function checkRevisionCount( SourceUrl $sourceUrl, array $pageInfoData ): void {
254        if ( count( $pageInfoData['revisions'] ) > $this->maxRevisions ||
255            count( $pageInfoData['imageinfo'] ) > $this->maxRevisions ||
256            count( $pageInfoData['revisions'] ) > static::MAX_REVISIONS ||
257            count( $pageInfoData['imageinfo'] ) > static::MAX_REVISIONS ) {
258            $this->logger->warning(
259                'Too many revisions were being fetched',
260                [
261                    'sourceUrl' => $sourceUrl->getUrl(),
262                ]
263            );
264
265            throw new LocalizedImportException( 'fileimporter-api-toomanyrevisions' );
266        }
267    }
268
269    /**
270     * @param array[] $pageInfoData
271     * @phan-param array{imageinfo:array{size:int}[]} $pageInfoData
272     *
273     * @throws ImportException when exceeding the maximum file size
274     */
275    private function checkMaxRevisionAggregatedBytes( array $pageInfoData ): void {
276        $aggregatedFileBytes = 0;
277        foreach ( $pageInfoData['imageinfo'] as $fileVersion ) {
278            $aggregatedFileBytes += $fileVersion['size'] ?? 0;
279            if ( $aggregatedFileBytes > $this->maxAggregatedBytes ||
280                $aggregatedFileBytes > static::MAX_AGGREGATED_BYTES ) {
281                $versions = count( $pageInfoData['imageinfo'] );
282                throw new LocalizedImportException( [ 'fileimporter-filetoolarge', $versions ] );
283            }
284        }
285    }
286
287    /**
288     * @param array[] $imageInfo
289     * @param string $pageTitle
290     *
291     * @throws ImportException when the file is not acceptable, e.g. hidden or to big
292     */
293    private function getFileRevisionsFromImageInfo( array $imageInfo, string $pageTitle ): FileRevisions {
294        $revisions = [];
295        foreach ( $imageInfo as $revisionInfo ) {
296            if ( ( $revisionInfo['filehidden'] ?? false ) !== false ) {
297                throw new LocalizedImportException( 'fileimporter-cantimportfilehidden' );
298            }
299
300            if ( ( $revisionInfo['filemissing'] ?? false ) !== false ) {
301                throw new LocalizedImportException( 'fileimporter-filemissinginrevision' );
302            }
303
304            if ( ( $revisionInfo['userhidden'] ?? false ) !== false ) {
305                $revisionInfo['user'] ??= $this->suppressedUsername;
306            }
307
308            if ( ( $revisionInfo['size'] ?? 0 ) > $this->maxBytes ) {
309                $versions = count( $imageInfo );
310                throw new LocalizedImportException( [ 'fileimporter-filetoolarge', $versions ] );
311            }
312
313            if ( isset( $revisionInfo['sha1'] ) ) {
314                // Convert from API sha1 format to DB sha1 format. The conversion can be se inside
315                // ApiQueryImageInfo.
316                // * API sha1 format is base 16 padded to 40 chars
317                // * DB sha1 format is base 36 padded to 31 chars
318                $revisionInfo['sha1'] = \Wikimedia\base_convert( $revisionInfo['sha1'], 16, 36, 31 );
319            }
320
321            if ( ( $revisionInfo['commenthidden'] ?? false ) !== false ) {
322                $revisionInfo['comment'] ??=
323                    wfMessage( 'fileimporter-revision-removed-comment' )->plain();
324            }
325
326            $revisionInfo['name'] = $pageTitle;
327            $revisionInfo['description'] = $revisionInfo['comment'] ?? null;
328
329            $revisions[] = new FileRevision( $revisionInfo );
330        }
331        return new FileRevisions( $revisions );
332    }
333
334    /**
335     * @param array[] $revisionsInfo
336     * @param string $pageTitle
337     */
338    private function getTextRevisionsFromRevisionsInfo( array $revisionsInfo, string $pageTitle ): TextRevisions {
339        $revisions = [];
340        foreach ( $revisionsInfo as $revisionInfo ) {
341            if ( ( $revisionInfo['userhidden'] ?? false ) !== false ) {
342                $revisionInfo['user'] ??= $this->suppressedUsername;
343            }
344
345            if ( ( $revisionInfo['texthidden'] ?? false ) !== false ) {
346                $revisionInfo['slots'][SlotRecord::MAIN]['content'] ??=
347                    wfMessage( 'fileimporter-revision-removed-text' )->plain();
348            }
349
350            if ( ( $revisionInfo['commenthidden'] ?? false ) !== false ) {
351                $revisionInfo['comment'] ??=
352                    wfMessage( 'fileimporter-revision-removed-comment' )->plain();
353            }
354
355            // Backwards-compatible with formatversion=1 where "" means true
356            $revisionInfo['minor'] = ( $revisionInfo['minor'] ?? false ) !== false;
357            $revisionInfo['title'] = $pageTitle;
358            $revisions[] = new TextRevision( $revisionInfo );
359        }
360        return new TextRevisions( $revisions );
361    }
362
363    private function getBaseParams( SourceUrl $sourceUrl ): array {
364        return [
365            'action' => 'query',
366            'errorformat' => 'plaintext',
367            'format' => 'json',
368            'formatversion' => '2',
369            'titles' => $this->parseTitleFromSourceUrl( $sourceUrl ),
370            'prop' => 'info'
371        ];
372    }
373
374    /**
375     * Adds to params base the properties for getting Text Revisions
376     */
377    private function addTextRevisionsToParams( array $params, ?string $rvContinue = null ): array {
378        $params['prop'] .= ( $params['prop'] ) ? '|revisions' : 'revisions';
379
380        if ( $rvContinue ) {
381            $params['rvcontinue'] = $rvContinue;
382        }
383
384        return $params + [
385            'rvlimit' => static::API_RESULT_LIMIT,
386            'rvdir' => 'newer',
387            'rvslots' => SlotRecord::MAIN,
388            'rvprop' => implode(
389                '|',
390                [
391                    'flags',
392                    'timestamp',
393                    'user',
394                    'sha1',
395                    'contentmodel',
396                    'comment',
397                    'content',
398                    'tags',
399                ]
400            )
401        ];
402    }
403
404    /**
405     * Adds to params base the properties for getting File Revisions
406     */
407    private function addFileRevisionsToParams( array $params, ?string $iiStart = null ): array {
408        $params['prop'] .= ( $params['prop'] ) ? '|imageinfo' : 'imageinfo';
409
410        if ( $iiStart ) {
411            $params['iistart'] = $iiStart;
412        }
413
414        return $params + [
415            'iilimit' => static::API_RESULT_LIMIT,
416            'iiurlwidth' => 800,
417            'iiurlheight' => 400,
418            'iiprop' => implode(
419                '|',
420                [
421                    'timestamp',
422                    'user',
423                    'userid',
424                    'comment',
425                    'canonicaltitle',
426                    'url',
427                    'size',
428                    'sha1',
429                    'archivename',
430                ]
431            )
432        ];
433    }
434
435    /**
436     * Adds to params base the properties for getting Templates
437     */
438    private function addTemplatesToParams( array $params, ?string $tlContinue = null ): array {
439        $params['prop'] .= ( $params['prop'] ) ? '|templates' : 'templates';
440
441        if ( $tlContinue ) {
442            $params['tlcontinue'] = $tlContinue;
443        }
444
445        return $params + [ 'tlnamespace' => NS_TEMPLATE, 'tllimit' => static::API_RESULT_LIMIT ];
446    }
447
448    /**
449     * Adds to params base the properties for getting Categories
450     */
451    private function addCategoriesToParams( array $params, ?string $clContinue = null ): array {
452        $params['prop'] .= ( $params['prop'] ) ? '|categories' : 'categories';
453
454        if ( $clContinue ) {
455            $params['clcontinue'] = $clContinue;
456        }
457
458        return $params + [ 'cllimit' => static::API_RESULT_LIMIT ];
459    }
460
461}