Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.22% covered (warning)
89.22%
149 / 167
50.00% covered (danger)
50.00%
5 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Importer
89.22% covered (warning)
89.22%
149 / 167
50.00% covered (danger)
50.00%
5 / 10
25.78
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 import
100.00% covered (success)
100.00%
43 / 43
100.00% covered (success)
100.00%
1 / 1
1
 buildImportOperations
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
3
 prepareImportOperations
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
3.19
 validateImportOperations
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
 commitImportOperations
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
3.19
 validateFileInfoText
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 getPageFromImportPlan
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
2.31
 createPostImportNullRevision
78.57% covered (warning)
78.57%
11 / 14
0.00% covered (danger)
0.00%
0 / 1
2.04
 createPostImportEdit
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
2.12
1<?php
2
3namespace FileImporter\Services;
4
5use FileImporter\Data\ImportDetails;
6use FileImporter\Data\ImportOperations;
7use FileImporter\Data\ImportPlan;
8use FileImporter\Exceptions\AbuseFilterWarningsException;
9use FileImporter\Exceptions\ImportException;
10use FileImporter\Exceptions\LocalizedImportException;
11use FileImporter\Operations\FileRevisionFromRemoteUrl;
12use FileImporter\Operations\TextRevisionFromTextRevision;
13use FileImporter\Services\Http\HttpRequestExecutor;
14use FileImporter\Services\UploadBase\UploadBaseFactory;
15use MediaWiki\Api\IApiMessage;
16use MediaWiki\Content\WikitextContent;
17use MediaWiki\MediaWikiServices;
18use MediaWiki\Page\WikiPage;
19use MediaWiki\Page\WikiPageFactory;
20use MediaWiki\Permissions\Authority;
21use MediaWiki\Permissions\RestrictionStore;
22use MediaWiki\Status\Status;
23use MediaWiki\Title\Title;
24use MediaWiki\User\User;
25use MediaWiki\User\UserIdentity;
26use MediaWiki\User\UserIdentityLookup;
27use OldRevisionImporter;
28use Psr\Log\LoggerInterface;
29use Psr\Log\NullLogger;
30use RuntimeException;
31use StatusValue;
32use UploadRevisionImporter;
33use Wikimedia\Message\MessageSpecifier;
34use Wikimedia\Rdbms\IDBAccessObject;
35use Wikimedia\Stats\StatsFactory;
36
37/**
38 * Performs an import of a file to the local wiki based on an ImportPlan object for a given User.
39 *
40 * @license GPL-2.0-or-later
41 * @author Addshore
42 */
43class Importer {
44
45    private const ERROR_NO_NEW_PAGE = 'noPageCreated';
46
47    private WikiPageFactory $wikiPageFactory;
48    private WikiRevisionFactory $wikiRevisionFactory;
49    private NullRevisionCreator $nullRevisionCreator;
50    private UserIdentityLookup $userLookup;
51    private HttpRequestExecutor $httpRequestExecutor;
52    private UploadBaseFactory $uploadBaseFactory;
53    private OldRevisionImporter $oldRevisionImporter;
54    private UploadRevisionImporter $uploadRevisionImporter;
55    private FileTextRevisionValidator $textRevisionValidator;
56    private RestrictionStore $restrictionStore;
57    private LoggerInterface $logger;
58    private StatsFactory $statsFactory;
59
60    public function __construct(
61        WikiPageFactory $wikiPageFactory,
62        WikiRevisionFactory $wikiRevisionFactory,
63        NullRevisionCreator $nullRevisionCreator,
64        UserIdentityLookup $userLookup,
65        HttpRequestExecutor $httpRequestExecutor,
66        UploadBaseFactory $uploadBaseFactory,
67        OldRevisionImporter $oldRevisionImporter,
68        UploadRevisionImporter $uploadRevisionImporter,
69        FileTextRevisionValidator $textRevisionValidator,
70        RestrictionStore $restrictionStore,
71        ?LoggerInterface $logger = null,
72        ?StatsFactory $statsFactory = null
73    ) {
74        $this->wikiPageFactory = $wikiPageFactory;
75        $this->wikiRevisionFactory = $wikiRevisionFactory;
76        $this->nullRevisionCreator = $nullRevisionCreator;
77        $this->userLookup = $userLookup;
78        $this->httpRequestExecutor = $httpRequestExecutor;
79        $this->uploadBaseFactory = $uploadBaseFactory;
80        $this->oldRevisionImporter = $oldRevisionImporter;
81        $this->uploadRevisionImporter = $uploadRevisionImporter;
82        $this->textRevisionValidator = $textRevisionValidator;
83        $this->restrictionStore = $restrictionStore;
84        $this->logger = $logger ?? new NullLogger();
85        $statsFactory ??= StatsFactory::newNull();
86        $this->statsFactory = $statsFactory->withComponent( 'FileImporter' );
87    }
88
89    /**
90     * @param User $user user to use for the import
91     * @param ImportPlan $importPlan A valid ImportPlan object.
92     *
93     * @throws ImportException|RuntimeException
94     */
95    public function import( User $user, ImportPlan $importPlan ) {
96        $this->wikiRevisionFactory->setInterWikiPrefix( $importPlan->getInterWikiPrefix() );
97        $metric = $this->statsFactory->getTiming( 'import_operation_duration_seconds' );
98
99        $importStart = microtime( true );
100        $this->logger->info( __METHOD__ . ' started' );
101
102        $validationStatus = $this->validateFileInfoText(
103            $user,
104            $importPlan
105        );
106
107        // TODO the type of ImportOperation created should be decided somewhere
108
109        $operationBuildingStart = microtime( true );
110        $importOperations = $this->buildImportOperations(
111            $user,
112            $importPlan->getTitle(),
113            $importPlan->getDetails()
114        );
115        $metric->setLabel( 'operation', 'build' )
116            ->copyToStatsdAt( 'FileImporter.import.timing.buildOperations' )
117            ->observeSeconds( microtime( true ) - $operationBuildingStart );
118
119        $operationPrepareStart = microtime( true );
120        $this->prepareImportOperations( $importOperations );
121        $metric->setLabel( 'operation', 'prepare' )
122            ->copyToStatsdAt( 'FileImporter.import.timing.prepareOperations' )
123            ->observeSeconds( microtime( true ) - $operationPrepareStart );
124
125        $operationValidateStart = microtime( true );
126        $validationStatus->merge( $importOperations->validate() );
127        $this->validateImportOperations( $validationStatus, $importPlan );
128        $metric->setLabel( 'operation', 'validate' )
129            ->copyToStatsdAt( 'FileImporter.import.timing.validateOperations' )
130            ->observeSeconds( microtime( true ) - $operationValidateStart );
131
132        $operationCommitStart = microtime( true );
133        $this->commitImportOperations( $importOperations );
134        $metric->setLabel( 'operation', 'commit' )
135            ->copyToStatsdAt( 'FileImporter.import.timing.commitOperations' )
136            ->observeSeconds( microtime( true ) - $operationCommitStart );
137
138        // TODO the below should be an ImportOperation
139        $miscActionsStart = microtime( true );
140        $page = $this->getPageFromImportPlan( $importPlan );
141        $this->createPostImportNullRevision( $importPlan, $user );
142        $this->createPostImportEdit( $importPlan, $page, $user );
143        $metric->setLabel( 'operation', 'misc' )
144            ->copyToStatsdAt( 'FileImporter.import.timing.miscActions' )
145            ->observeSeconds( microtime( true ) - $miscActionsStart );
146
147        // TODO do we need to call WikiImporter::finishImportPage??
148        // TODO factor logic in WikiImporter::finishImportPage out so we can call it
149
150        $this->statsFactory->getTiming( 'import_duration_seconds' )
151            ->copyToStatsdAt( 'FileImporter.import.timing.wholeImport' )
152            ->observeSeconds( microtime( true ) - $importStart );
153    }
154
155    /**
156     * @return ImportOperations
157     */
158    private function buildImportOperations(
159        User $user,
160        Title $plannedTitle,
161        ImportDetails $importDetails
162    ) {
163        $textRevisions = $importDetails->getTextRevisions()->toArray();
164        $fileRevisions = $importDetails->getFileRevisions()->toArray();
165        $importOperations = new ImportOperations();
166
167        /**
168         * Text revisions should be added first. See T147451.
169         * This ensures that the page entry is created and if something fails it can thus be deleted.
170         */
171        foreach ( $textRevisions as $textRevision ) {
172            $importOperations->add( new TextRevisionFromTextRevision(
173                $plannedTitle,
174                $user,
175                $textRevision,
176                $this->wikiRevisionFactory,
177                $this->oldRevisionImporter,
178                $this->textRevisionValidator,
179                $this->restrictionStore,
180                $this->logger
181            ) );
182        }
183
184        $totalFileSizes = 0;
185        $initialTextRevision = $textRevisions[0] ?? null;
186
187        foreach ( $fileRevisions as $fileRevision ) {
188            $totalFileSizes += $fileRevision->getField( 'size' );
189            $importOperations->add( new FileRevisionFromRemoteUrl(
190                $plannedTitle,
191                $user,
192                $fileRevision,
193                $initialTextRevision,
194                $this->userLookup,
195                $this->httpRequestExecutor,
196                $this->wikiRevisionFactory,
197                $this->uploadBaseFactory,
198                $this->uploadRevisionImporter,
199                $this->restrictionStore,
200                $this->logger
201            ) );
202
203            // only include the initial text revision in the first upload
204            $initialTextRevision = null;
205        }
206        $this->statsFactory->getGauge( 'import_details_textRevisions' )
207            ->copyToStatsdAt( 'FileImporter.import.details.textRevisions' )
208            ->set( count( $textRevisions ) );
209        $this->statsFactory->getGauge( 'import_details_fileRevisions' )
210            ->copyToStatsdAt( 'FileImporter.import.details.fileRevisions' )
211            ->set( count( $fileRevisions ) );
212
213        $this->statsFactory->getGauge( 'import_details_totalFileSizes_bytes' )
214            ->copyToStatsdAt( 'FileImporter.import.details.totalFileSizes' )
215            ->set( $totalFileSizes );
216
217        return $importOperations;
218    }
219
220    private function prepareImportOperations( ImportOperations $importOperations ): void {
221        $status = $importOperations->prepare();
222        if ( !$status->isOK() ) {
223            $this->logger->error( __METHOD__ . ' Failed to prepare operations.', [
224                'status' => $status->__toString(),
225            ] );
226            throw new LocalizedImportException( Status::wrap( $status )->getMessage() );
227        }
228    }
229
230    private function validateImportOperations( StatusValue $status, ImportPlan $importPlan ): void {
231        if ( !$status->isGood() ) {
232            /** @var MessageSpecifier[] $newAbuseFilterWarnings */
233            $newAbuseFilterWarnings = [];
234
235            foreach ( $status->getMessages() as $msg ) {
236                if ( !( $msg instanceof IApiMessage ) ) {
237                    // Unexpected errors bubble up and surface in SpecialImportFile::doImport
238                    throw new LocalizedImportException( $msg );
239                }
240
241                $data = $msg->getApiData()['abusefilter'] ?? null;
242                if ( !$data ||
243                    !in_array( 'warn', $data['actions'] ) ||
244                    in_array( 'disallow', $data['actions'] )
245                ) {
246                    throw new LocalizedImportException( $msg );
247                }
248
249                // Skip AbuseFilter warnings we have seen before
250                if ( !in_array( $data['id'], $importPlan->getValidationWarnings() ) ) {
251                    // @phan-suppress-next-line PhanTypeMismatchArgument
252                    $importPlan->addValidationWarning( $data['id'] );
253                    $newAbuseFilterWarnings[] = $msg;
254                }
255            }
256
257            if ( $newAbuseFilterWarnings ) {
258                throw new AbuseFilterWarningsException( $newAbuseFilterWarnings );
259            }
260        }
261    }
262
263    private function commitImportOperations( ImportOperations $importOperations ): void {
264        $status = $importOperations->commit();
265        if ( !$status->isOK() ) {
266            $this->logger->error( __METHOD__ . ' Failed to commit operations.', [
267                'status' => $status->__toString(),
268            ] );
269            throw new LocalizedImportException( Status::wrap( $status )->getMessage() );
270        }
271    }
272
273    /**
274     * @return StatusValue isOK on success
275     */
276    private function validateFileInfoText(
277        User $user,
278        ImportPlan $importPlan
279    ): StatusValue {
280        $status = $this->textRevisionValidator->validate(
281            $importPlan->getTitle(),
282            $user,
283            new WikitextContent( $importPlan->getFileInfoText() ),
284            $importPlan->getRequest()->getIntendedSummary() ?? '',
285            false
286        );
287        return $status;
288    }
289
290    /**
291     * @return WikiPage
292     * @throws ImportException
293     */
294    private function getPageFromImportPlan( ImportPlan $importPlan ) {
295        // T164729: READ_LATEST needed to select for a write
296        $articleIdForUpdate = $importPlan->getTitle()->getArticleID( IDBAccessObject::READ_LATEST );
297        // T181391: Read from primary database, as the page has only just been created, and in multi-DB setups
298        // replicas will have lag.
299        $page = $this->wikiPageFactory->newFromId( $articleIdForUpdate, IDBAccessObject::READ_LATEST );
300
301        if ( !$page ) {
302            throw new ImportException(
303                'Failed to create import edit with page id: ' . $articleIdForUpdate,
304                self::ERROR_NO_NEW_PAGE );
305        }
306
307        return $page;
308    }
309
310    private function createPostImportNullRevision(
311        ImportPlan $importPlan,
312        UserIdentity $user
313    ): void {
314        $config = MediaWikiServices::getInstance()->getMainConfig();
315        $summary = wfMsgReplaceArgs(
316            $config->get( 'FileImporterCommentForPostImportRevision' ),
317            [ $importPlan->getRequest()->getUrl() ]
318        );
319
320        try {
321            $this->nullRevisionCreator->createForLinkTarget(
322                $importPlan->getTitle(),
323                $importPlan->getDetails()->getFileRevisions()->getLatest(),
324                $user,
325                $summary
326            );
327        } catch ( RuntimeException $ex ) {
328            $this->logger->error( __METHOD__ . ' Failed to create import revision.' );
329            throw $ex;
330        }
331    }
332
333    /**
334     * @param ImportPlan $importPlan
335     * @param WikiPage $page
336     * @param Authority $user
337     */
338    private function createPostImportEdit(
339        ImportPlan $importPlan,
340        WikiPage $page,
341        Authority $user
342    ) {
343        // TODO: Replace with $page->newPageUpdater( … )->saveRevision( … )
344        $editResult = $page->doUserEditContent(
345            new WikitextContent( $importPlan->getFileInfoText() ),
346            $user,
347            $importPlan->getRequest()->getIntendedSummary(),
348            EDIT_UPDATE,
349            false,
350            [ 'fileimporter' ]
351        );
352
353        if ( !$editResult->isOK() ) {
354            $this->logger->error( __METHOD__ . ' Failed to create user edit.', [
355                'status' => $editResult->__toString(),
356            ] );
357            throw new LocalizedImportException( Status::wrap( $editResult )->getMessage() );
358        }
359    }
360
361}